@nordbyte/nordrelay 0.3.1 → 0.4.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.
Files changed (48) hide show
  1. package/.env.example +45 -2
  2. package/README.md +204 -30
  3. package/dist/agent-activity.js +300 -0
  4. package/dist/agent-adapter.js +17 -30
  5. package/dist/agent-factory.js +27 -0
  6. package/dist/agent.js +123 -9
  7. package/dist/artifacts.js +1 -1
  8. package/dist/audit-log.js +1 -1
  9. package/dist/bot-ui.js +1 -1
  10. package/dist/bot.js +328 -159
  11. package/dist/claude-code-auth.js +121 -0
  12. package/dist/claude-code-cli.js +19 -0
  13. package/dist/claude-code-launch.js +73 -0
  14. package/dist/claude-code-session.js +660 -0
  15. package/dist/claude-code-state.js +590 -0
  16. package/dist/codex-session.js +12 -1
  17. package/dist/config.js +113 -9
  18. package/dist/hermes-api.js +150 -0
  19. package/dist/hermes-auth.js +96 -0
  20. package/dist/hermes-cli.js +19 -0
  21. package/dist/hermes-launch.js +57 -0
  22. package/dist/hermes-session.js +477 -0
  23. package/dist/hermes-state.js +609 -0
  24. package/dist/index.js +51 -8
  25. package/dist/openclaw-auth.js +27 -0
  26. package/dist/openclaw-cli.js +19 -0
  27. package/dist/openclaw-gateway.js +285 -0
  28. package/dist/openclaw-launch.js +65 -0
  29. package/dist/openclaw-session.js +549 -0
  30. package/dist/openclaw-state.js +409 -0
  31. package/dist/operations.js +83 -2
  32. package/dist/pi-auth.js +59 -0
  33. package/dist/pi-launch.js +61 -0
  34. package/dist/pi-rpc.js +18 -0
  35. package/dist/pi-session.js +103 -15
  36. package/dist/pi-state.js +253 -0
  37. package/dist/relay-runtime.js +673 -51
  38. package/dist/session-format.js +28 -18
  39. package/dist/session-registry.js +40 -15
  40. package/dist/settings-service.js +35 -4
  41. package/dist/web-dashboard-ui.js +18 -0
  42. package/dist/web-dashboard.js +329 -47
  43. package/package.json +8 -3
  44. package/plugins/nordrelay/.codex-plugin/plugin.json +7 -4
  45. package/plugins/nordrelay/commands/remote.md +2 -2
  46. package/plugins/nordrelay/scripts/nordrelay.mjs +131 -3
  47. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +2 -2
  48. package/CHANGELOG.md +0 -26
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { PI_AGENT_CAPABILITIES, PI_THINKING_LEVELS, } from "./agent.js";
5
- import { createDefaultLaunchProfile } from "./codex-launch.js";
5
+ import { findPiLaunchProfile, listPiLaunchProfiles, piProfileAsLaunchProfile } from "./pi-launch.js";
6
6
  import { resolvePiCli } from "./pi-cli.js";
7
7
  import { PiRpcClient } from "./pi-rpc.js";
8
8
  import { getPiSession, listPiSessions, listPiWorkspaces, readPiSessionRecord, resolvePiSessionDir, } from "./pi-state.js";
@@ -10,7 +10,7 @@ export class PiSessionService {
10
10
  config;
11
11
  sessionDir;
12
12
  cliPath;
13
- launchProfile;
13
+ currentLaunchProfile;
14
14
  rpc = null;
15
15
  currentWorkspace;
16
16
  currentThreadId = null;
@@ -31,13 +31,14 @@ export class PiSessionService {
31
31
  this.currentWorkspace = config.workspace;
32
32
  this.currentModel = config.piDefaultModel;
33
33
  this.currentThinking = config.piDefaultThinking;
34
- this.launchProfile = createDefaultLaunchProfile("workspace-write", "never");
34
+ this.currentLaunchProfile = findPiLaunchProfile(config.piDefaultLaunchProfileId);
35
35
  }
36
36
  static async create(config, options) {
37
37
  const service = new PiSessionService(config);
38
38
  service.currentWorkspace = options?.workspace ?? config.workspace;
39
39
  service.currentModel = options?.model ?? config.piDefaultModel;
40
40
  service.currentThinking = options?.reasoningEffort ?? config.piDefaultThinking;
41
+ service.currentLaunchProfile = findPiLaunchProfile(options?.launchProfileId ?? config.piDefaultLaunchProfileId);
41
42
  if (options?.sessionPath) {
42
43
  await service.switchSession(options.sessionPath);
43
44
  return service;
@@ -59,9 +60,9 @@ export class PiSessionService {
59
60
  workspace: this.currentWorkspace,
60
61
  model: this.currentModel,
61
62
  reasoningEffort: this.currentThinking,
62
- launchProfileId: "pi-rpc",
63
- launchProfileLabel: "Pi RPC",
64
- launchProfileBehavior: "rpc / host",
63
+ launchProfileId: this.currentLaunchProfile.id,
64
+ launchProfileLabel: this.currentLaunchProfile.label,
65
+ launchProfileBehavior: this.currentLaunchProfile.behavior,
65
66
  sandboxMode: "host",
66
67
  approvalPolicy: "never",
67
68
  fastMode: false,
@@ -220,6 +221,9 @@ export class PiSessionService {
220
221
  workspaces.add(this.config.workspace);
221
222
  return [...workspaces].sort((left, right) => left.localeCompare(right));
222
223
  }
224
+ async refreshModels() {
225
+ // Pi models are read from the CLI on each listModels() call.
226
+ }
223
227
  listModels() {
224
228
  const result = spawnSync(this.cliPath, ["--list-models"], {
225
229
  cwd: this.currentWorkspace,
@@ -243,11 +247,17 @@ export class PiSessionService {
243
247
  continue;
244
248
  }
245
249
  const slug = `${provider}/${model}`;
246
- const maxInputTokens = parseCompactTokenCount(parts[2]);
250
+ const contextWindow = parseCompactTokenCount(parts[2]);
251
+ const maxOutputTokens = parseCompactTokenCount(parts[3]);
252
+ const supportsThinking = parseYesNo(parts[4]);
253
+ const supportsImages = parseYesNo(parts[5]);
247
254
  records.push({
248
255
  slug,
249
256
  displayName: slug,
250
- ...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
257
+ ...(contextWindow !== undefined ? { maxInputTokens: contextWindow, contextWindow } : {}),
258
+ ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
259
+ ...(supportsThinking !== undefined ? { supportsThinking } : {}),
260
+ ...(supportsImages !== undefined ? { supportsImages } : {}),
251
261
  });
252
262
  }
253
263
  if (this.currentModel && !records.some((record) => record.slug === this.currentModel)) {
@@ -255,6 +265,9 @@ export class PiSessionService {
255
265
  }
256
266
  return records;
257
267
  }
268
+ listLaunchProfiles() {
269
+ return listPiLaunchProfiles();
270
+ }
258
271
  getSessionRecord(threadId) {
259
272
  return getPiSession(threadId, { sessionDir: this.sessionDir });
260
273
  }
@@ -297,21 +310,68 @@ export class PiSessionService {
297
310
  }
298
311
  return { value: level, appliedToActiveThread };
299
312
  }
300
- setLaunchProfile() {
301
- throw new Error("Launch profiles are only supported by Codex sessions");
313
+ setLaunchProfile(profileId) {
314
+ this.ensureIdle("change Pi profile");
315
+ this.currentLaunchProfile = findPiLaunchProfile(profileId);
316
+ this.restartRpcIfIdle();
317
+ return piProfileAsLaunchProfile(this.currentLaunchProfile);
302
318
  }
303
319
  setFastMode() {
304
320
  throw new Error("Fast mode is only supported by Codex sessions");
305
321
  }
306
322
  getSelectedLaunchProfile() {
307
- return this.launchProfile;
323
+ return piProfileAsLaunchProfile(this.currentLaunchProfile);
308
324
  }
309
- syncFromCodexState() {
325
+ syncFromAgentState(options = {}) {
326
+ const before = {
327
+ threadId: this.currentThreadId,
328
+ workspace: this.currentWorkspace,
329
+ model: this.currentModel,
330
+ thinking: this.currentThinking,
331
+ sessionPath: this.currentSessionPath,
332
+ };
333
+ const changedFields = new Set();
334
+ const record = this.currentSessionPath
335
+ ? readPiSessionRecord(this.currentSessionPath, path.basename(path.dirname(this.currentSessionPath)))
336
+ : this.currentThreadId
337
+ ? getPiSession(this.currentThreadId, { sessionDir: this.sessionDir })
338
+ : null;
339
+ if (record) {
340
+ if (record.id !== this.currentThreadId)
341
+ changedFields.add("thread");
342
+ if (record.cwd && record.cwd !== this.currentWorkspace)
343
+ changedFields.add("workspace");
344
+ if (record.model && record.model !== this.currentModel)
345
+ changedFields.add("model");
346
+ if (record.reasoningEffort && record.reasoningEffort !== this.currentThinking)
347
+ changedFields.add("reasoning");
348
+ if (record.sessionPath !== this.currentSessionPath)
349
+ changedFields.add("sessionPath");
350
+ this.currentThreadId = record.id;
351
+ this.currentWorkspace = record.cwd || this.currentWorkspace;
352
+ this.currentSessionPath = record.sessionPath;
353
+ this.currentModel = record.model ?? this.currentModel;
354
+ this.currentThinking = record.reasoningEffort ?? this.currentThinking;
355
+ if (changedFields.has("thread") || changedFields.has("sessionPath")) {
356
+ this.cachedStats = {};
357
+ }
358
+ }
359
+ const changed = changedFields.size > 0 ||
360
+ before.threadId !== this.currentThreadId ||
361
+ before.workspace !== this.currentWorkspace ||
362
+ before.model !== this.currentModel ||
363
+ before.thinking !== this.currentThinking ||
364
+ before.sessionPath !== this.currentSessionPath;
365
+ let reattached = false;
366
+ if (changed && options.reattach && this.rpc && !this.processing) {
367
+ this.restartRpc();
368
+ reattached = true;
369
+ }
310
370
  return {
311
371
  threadId: this.currentThreadId,
312
- changed: false,
313
- reattached: false,
314
- changedFields: [],
372
+ changed,
373
+ reattached,
374
+ changedFields: [...changedFields],
315
375
  info: this.getInfo(),
316
376
  };
317
377
  }
@@ -354,6 +414,7 @@ export class PiSessionService {
354
414
  sessionPath: this.currentSessionPath,
355
415
  model: this.currentModel,
356
416
  thinking: this.currentThinking,
417
+ ...this.currentLaunchProfile.cli,
357
418
  env: { PI_CODING_AGENT_SESSION_DIR: this.sessionDir },
358
419
  });
359
420
  }
@@ -433,11 +494,25 @@ export class PiSessionService {
433
494
  data: (await readFile(imagePath)).toString("base64"),
434
495
  mimeType: mimeTypeForImage(imagePath),
435
496
  })));
497
+ if (images.length > 0) {
498
+ const imageSupport = this.currentModelSupportsImages();
499
+ if (imageSupport === false) {
500
+ throw new Error(`Current Pi model does not support image input: ${this.currentModel}`);
501
+ }
502
+ }
436
503
  return {
437
504
  message: textParts.join("\n\n") || "Please inspect the attached file(s).",
438
505
  ...(images.length > 0 ? { images } : {}),
439
506
  };
440
507
  }
508
+ currentModelSupportsImages() {
509
+ const model = this.currentModel;
510
+ if (!model) {
511
+ return null;
512
+ }
513
+ const record = this.listModels().find((candidate) => candidate.slug === model || candidate.slug.endsWith(`/${model}`));
514
+ return record?.supportsImages ?? null;
515
+ }
441
516
  refreshFromTurnEnd(event, callbacks) {
442
517
  const message = objectValue(event.message);
443
518
  const usage = objectValue(message?.usage);
@@ -556,6 +631,19 @@ function parseCompactTokenCount(value) {
556
631
  const multiplier = unit === "M" ? 1_000_000 : unit === "K" ? 1_000 : unit === "B" ? 1_000_000_000 : 1;
557
632
  return Math.round(number * multiplier);
558
633
  }
634
+ function parseYesNo(value) {
635
+ if (!value) {
636
+ return undefined;
637
+ }
638
+ const normalized = value.toLowerCase();
639
+ if (["yes", "true", "1"].includes(normalized)) {
640
+ return true;
641
+ }
642
+ if (["no", "false", "0"].includes(normalized)) {
643
+ return false;
644
+ }
645
+ return undefined;
646
+ }
559
647
  function mimeTypeForImage(filePath) {
560
648
  switch (path.extname(filePath).toLowerCase()) {
561
649
  case ".png":
package/dist/pi-state.js CHANGED
@@ -48,6 +48,58 @@ export function getPiSession(idOrPath, options = {}) {
48
48
  path.basename(record.sessionPath, ".jsonl").endsWith(`_${normalized}`));
49
49
  return matches[0] ?? null;
50
50
  }
51
+ export function getPiSessionActivity(idOrPath, options = {}) {
52
+ return getPiSessionSnapshot(idOrPath, { ...options, maxEvents: 0 })?.activity ?? null;
53
+ }
54
+ export function getPiSessionActivityLog(idOrPath, limit = 50, options = {}) {
55
+ const snapshot = getPiSessionSnapshot(idOrPath, { ...options, maxEvents: Math.max(1, limit) });
56
+ return snapshot?.events.slice(-Math.max(1, limit)) ?? [];
57
+ }
58
+ export function getPiSessionSnapshot(idOrPath, options = {}) {
59
+ const record = getPiSession(idOrPath, options);
60
+ if (!record) {
61
+ return null;
62
+ }
63
+ return readPiSessionSnapshot(record, options);
64
+ }
65
+ export function getPiSessionDiagnostics(idOrPath, options = {}) {
66
+ const sessionDir = resolvePiSessionDir(options);
67
+ if (!idOrPath) {
68
+ return {
69
+ sessionDir,
70
+ sessionPath: null,
71
+ lineCount: 0,
72
+ status: "unavailable",
73
+ reason: "no active Pi session",
74
+ updatedAt: null,
75
+ };
76
+ }
77
+ const snapshot = getPiSessionSnapshot(idOrPath, { ...options, maxEvents: 0 });
78
+ if (!snapshot) {
79
+ return {
80
+ sessionDir,
81
+ sessionPath: null,
82
+ lineCount: 0,
83
+ status: "unavailable",
84
+ reason: "session file not found or unreadable",
85
+ updatedAt: null,
86
+ };
87
+ }
88
+ const status = snapshot.activity.active ? "active" : snapshot.activity.stale ? "stale" : "idle";
89
+ const reason = snapshot.activity.active
90
+ ? "latest Pi user turn has no assistant response yet"
91
+ : snapshot.activity.stale
92
+ ? "open Pi turn exceeded stale timeout"
93
+ : "latest Pi turn has a terminal response";
94
+ return {
95
+ sessionDir,
96
+ sessionPath: snapshot.sourcePath,
97
+ lineCount: snapshot.lineCount,
98
+ status,
99
+ reason,
100
+ updatedAt: snapshot.activity.updatedAt,
101
+ };
102
+ }
51
103
  export function listPiWorkspaces(options = {}) {
52
104
  const workspaces = new Set();
53
105
  for (const record of listPiSessions(500, options)) {
@@ -131,6 +183,178 @@ export function readPiSessionRecord(sessionPath, workspaceSlug) {
131
183
  return null;
132
184
  }
133
185
  }
186
+ function readPiSessionSnapshot(record, options = {}) {
187
+ try {
188
+ const fileStat = statSync(record.sessionPath);
189
+ const lines = readFileSync(record.sessionPath, "utf8")
190
+ .split(/\r?\n/)
191
+ .filter(Boolean);
192
+ const parsed = parsePiActivityEvents(lines, record.id, 0);
193
+ const staleAfterMs = options.staleAfterMs ?? 5 * 60 * 1000;
194
+ const nowMs = options.nowMs ?? Date.now();
195
+ const latestUser = [...parsed.events].reverse().find((event) => event.kind === "user");
196
+ const latestAgent = [...parsed.events].reverse().find((event) => event.kind === "agent");
197
+ const latestTerminal = [...parsed.events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
198
+ const latestTool = [...parsed.events].reverse().find((event) => event.kind === "tool" && event.toolName);
199
+ const latestTimestamp = parsed.latestTimestamp ?? fileStat.mtime;
200
+ const activeStartedAt = latestUser?.timestamp ?? null;
201
+ const hasAssistantAfterUser = Boolean(latestUser && latestAgent && latestAgent.lineNumber > latestUser.lineNumber);
202
+ const terminalAfterUser = Boolean(latestUser && latestTerminal && latestTerminal.lineNumber > latestUser.lineNumber);
203
+ const openTurn = Boolean(latestUser && !hasAssistantAfterUser && !terminalAfterUser);
204
+ const stale = openTurn && nowMs - latestTimestamp.getTime() > staleAfterMs;
205
+ const active = openTurn && !stale;
206
+ const turnId = latestUser?.turnId ?? latestTerminal?.turnId ?? null;
207
+ const maxEvents = options.maxEvents ?? 50;
208
+ const afterLine = options.afterLine ?? 0;
209
+ const events = maxEvents <= 0 ? [] : parsed.events.filter((event) => event.lineNumber > afterLine).slice(-maxEvents);
210
+ return {
211
+ agentId: "pi",
212
+ agentLabel: "Pi",
213
+ threadId: record.id,
214
+ sourcePath: record.sessionPath,
215
+ sourceLabel: "Pi session",
216
+ lineCount: lines.length,
217
+ activity: {
218
+ agentId: "pi",
219
+ agentLabel: "Pi",
220
+ threadId: record.id,
221
+ sourcePath: record.sessionPath,
222
+ sourceLabel: "Pi session",
223
+ active,
224
+ stale,
225
+ turnId,
226
+ startedAt: activeStartedAt,
227
+ updatedAt: latestTimestamp,
228
+ },
229
+ events,
230
+ latestAgentMessage: latestAgent?.text ?? null,
231
+ latestUserMessage: latestUser?.text ?? null,
232
+ latestToolName: latestTool?.toolName ?? null,
233
+ };
234
+ }
235
+ catch {
236
+ return null;
237
+ }
238
+ }
239
+ function parsePiActivityEvents(lines, sessionId, afterLine) {
240
+ const events = [];
241
+ let latestTimestamp = null;
242
+ let currentTurnId = null;
243
+ for (const [index, line] of lines.entries()) {
244
+ const lineNumber = index + 1;
245
+ const entry = safeJsonParse(line);
246
+ if (!entry) {
247
+ continue;
248
+ }
249
+ const timestamp = dateValue(entry.timestamp) ?? dateValue(objectValue(entry.message)?.timestamp);
250
+ if (timestamp && (!latestTimestamp || timestamp > latestTimestamp)) {
251
+ latestTimestamp = timestamp;
252
+ }
253
+ const type = stringValue(entry.type) ?? "entry";
254
+ if (type === "message") {
255
+ const message = objectValue(entry.message);
256
+ const role = stringValue(message?.role);
257
+ const text = message ? extractMessageText(message) : null;
258
+ if (role === "user") {
259
+ currentTurnId = `pi-${sessionId}-${lineNumber}`;
260
+ pushEvent(events, afterLine, {
261
+ lineNumber,
262
+ kind: "task",
263
+ timestamp,
264
+ type: "turn",
265
+ turnId: currentTurnId,
266
+ status: "started",
267
+ text,
268
+ toolName: null,
269
+ phase: null,
270
+ });
271
+ pushEvent(events, afterLine, {
272
+ lineNumber,
273
+ kind: "user",
274
+ timestamp,
275
+ type,
276
+ turnId: currentTurnId,
277
+ status: null,
278
+ text,
279
+ toolName: null,
280
+ phase: null,
281
+ });
282
+ }
283
+ else if (role === "assistant") {
284
+ pushEvent(events, afterLine, {
285
+ lineNumber,
286
+ kind: "agent",
287
+ timestamp,
288
+ type,
289
+ turnId: currentTurnId,
290
+ status: "completed",
291
+ text,
292
+ toolName: null,
293
+ phase: null,
294
+ });
295
+ pushEvent(events, afterLine, {
296
+ lineNumber,
297
+ kind: "task",
298
+ timestamp,
299
+ type: "turn",
300
+ turnId: currentTurnId,
301
+ status: "completed",
302
+ text: null,
303
+ toolName: null,
304
+ phase: null,
305
+ });
306
+ }
307
+ else if (role === "tool") {
308
+ pushEvent(events, afterLine, {
309
+ lineNumber,
310
+ kind: "tool",
311
+ timestamp,
312
+ type,
313
+ turnId: currentTurnId,
314
+ status: "finished",
315
+ text,
316
+ toolName: stringValue(message?.name) ?? extractToolName(message) ?? "tool",
317
+ phase: null,
318
+ });
319
+ }
320
+ continue;
321
+ }
322
+ if (/tool/i.test(type)) {
323
+ const status = /start/i.test(type) ? "started" : /error|fail/i.test(type) ? "failed" : /end|finish|complete/i.test(type) ? "finished" : null;
324
+ pushEvent(events, afterLine, {
325
+ lineNumber,
326
+ kind: "tool",
327
+ timestamp,
328
+ type,
329
+ turnId: currentTurnId,
330
+ status,
331
+ text: extractContentText(entry),
332
+ toolName: stringValue(entry.toolName) ?? stringValue(entry.name) ?? "tool",
333
+ phase: null,
334
+ });
335
+ continue;
336
+ }
337
+ if (/error|fail/i.test(type)) {
338
+ pushEvent(events, afterLine, {
339
+ lineNumber,
340
+ kind: "task",
341
+ timestamp,
342
+ type,
343
+ turnId: currentTurnId,
344
+ status: "failed",
345
+ text: stringValue(entry.error) ?? stringValue(entry.message),
346
+ toolName: null,
347
+ phase: null,
348
+ });
349
+ }
350
+ }
351
+ return { events, latestTimestamp };
352
+ }
353
+ function pushEvent(events, afterLine, event) {
354
+ if (event.lineNumber > afterLine) {
355
+ events.push(event);
356
+ }
357
+ }
134
358
  function safeReadDir(directory) {
135
359
  try {
136
360
  return readdirSync(directory);
@@ -217,6 +441,35 @@ function extractMessageText(message) {
217
441
  .filter(Boolean);
218
442
  return parts.join("\n").trim() || null;
219
443
  }
444
+ function extractContentText(container) {
445
+ const direct = stringValue(container.text) ?? stringValue(container.error);
446
+ if (direct) {
447
+ return direct;
448
+ }
449
+ const content = container.content;
450
+ if (typeof content === "string") {
451
+ return content.trim() || null;
452
+ }
453
+ if (!Array.isArray(content)) {
454
+ return null;
455
+ }
456
+ const text = content
457
+ .map((entry) => {
458
+ const block = objectValue(entry);
459
+ return block ? stringValue(block.text) ?? stringValue(block.content) ?? "" : "";
460
+ })
461
+ .filter(Boolean)
462
+ .join("\n")
463
+ .trim();
464
+ return text || null;
465
+ }
466
+ function extractToolName(message) {
467
+ if (!message) {
468
+ return null;
469
+ }
470
+ const toolCall = objectValue(message.toolCall) ?? objectValue(message.tool_call);
471
+ return stringValue(toolCall?.name) ?? stringValue(toolCall?.toolName) ?? stringValue(message.toolName);
472
+ }
220
473
  function summarizeTitle(text) {
221
474
  if (!text) {
222
475
  return null;