@nordbyte/nordrelay 0.2.1
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/.env.example +88 -0
- package/Dockerfile +19 -0
- package/LICENSE +21 -0
- package/README.md +749 -0
- package/dist/access-control.js +146 -0
- package/dist/agent-factory.js +22 -0
- package/dist/agent.js +57 -0
- package/dist/artifacts.js +515 -0
- package/dist/attachments.js +69 -0
- package/dist/bot-preferences.js +146 -0
- package/dist/bot-ui.js +161 -0
- package/dist/bot.js +4520 -0
- package/dist/codex-auth.js +150 -0
- package/dist/codex-cli.js +79 -0
- package/dist/codex-config.js +50 -0
- package/dist/codex-launch.js +109 -0
- package/dist/codex-session.js +591 -0
- package/dist/codex-state.js +573 -0
- package/dist/config.js +385 -0
- package/dist/context-key.js +23 -0
- package/dist/error-messages.js +73 -0
- package/dist/format.js +121 -0
- package/dist/index.js +140 -0
- package/dist/logger.js +27 -0
- package/dist/operations.js +133 -0
- package/dist/persistence.js +65 -0
- package/dist/pi-cli.js +19 -0
- package/dist/pi-rpc.js +158 -0
- package/dist/pi-session.js +573 -0
- package/dist/pi-state.js +226 -0
- package/dist/prompt-store.js +241 -0
- package/dist/redaction.js +47 -0
- package/dist/session-format.js +191 -0
- package/dist/session-registry.js +195 -0
- package/dist/telegram-rate-limit.js +136 -0
- package/dist/voice.js +373 -0
- package/dist/workspace-policy.js +41 -0
- package/docker-compose.yml +17 -0
- package/launchd/start.sh +8 -0
- package/package.json +69 -0
- package/plugins/nordrelay/.codex-plugin/plugin.json +48 -0
- package/plugins/nordrelay/assets/nordrelay.svg +5 -0
- package/plugins/nordrelay/commands/remote.md +33 -0
- package/plugins/nordrelay/scripts/nordrelay.mjs +396 -0
- package/plugins/nordrelay/skills/telegram-remote/SKILL.md +26 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createBot, registerCommands } from "./bot.js";
|
|
4
|
+
import { checkAuthStatus } from "./codex-auth.js";
|
|
5
|
+
import { describeCodexCli, resolveCodexCli } from "./codex-cli.js";
|
|
6
|
+
import { findLaunchProfile, formatLaunchProfileBehavior } from "./codex-launch.js";
|
|
7
|
+
import { enabledAgents } from "./agent-factory.js";
|
|
8
|
+
import { loadConfig } from "./config.js";
|
|
9
|
+
import { installConsoleLogger } from "./logger.js";
|
|
10
|
+
import { describePiCli, resolvePiCli } from "./pi-cli.js";
|
|
11
|
+
import { configureRedaction } from "./redaction.js";
|
|
12
|
+
import { SessionRegistry } from "./session-registry.js";
|
|
13
|
+
let registry;
|
|
14
|
+
let bot;
|
|
15
|
+
try {
|
|
16
|
+
const config = loadConfig();
|
|
17
|
+
configureRedaction(config.telegramRedactPatterns);
|
|
18
|
+
installConsoleLogger(config.logFormat);
|
|
19
|
+
registry = new SessionRegistry(config);
|
|
20
|
+
bot = createBot(config, registry);
|
|
21
|
+
await registerCommands(bot);
|
|
22
|
+
console.log("NordRelay running");
|
|
23
|
+
const authStatus = config.codexEnabled
|
|
24
|
+
? await checkAuthStatus(config.codexApiKey)
|
|
25
|
+
: { authenticated: true, method: "codex-disabled" };
|
|
26
|
+
if (config.codexEnabled) {
|
|
27
|
+
console.log(`Auth: ${authStatus.authenticated ? "authenticated" : "not authenticated"} (${authStatus.method})`);
|
|
28
|
+
if (!authStatus.authenticated) {
|
|
29
|
+
console.warn("Warning: Codex is not authenticated. Use /login or set CODEX_API_KEY.");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
console.log(`Workspace: ${config.workspace}`);
|
|
33
|
+
console.log(`Enabled agents: ${enabledAgents(config).join(", ")} (default: ${config.defaultAgent})`);
|
|
34
|
+
if (config.codexModel) {
|
|
35
|
+
console.log(`Default model: ${config.codexModel}`);
|
|
36
|
+
}
|
|
37
|
+
const codexCli = resolveCodexCli();
|
|
38
|
+
const piCli = resolvePiCli(process.env, config.piCliPath);
|
|
39
|
+
console.log(`Codex CLI: ${describeCodexCli(codexCli)}`);
|
|
40
|
+
console.log(`Pi CLI: ${describePiCli(piCli)}`);
|
|
41
|
+
const defaultLaunchProfile = findLaunchProfile(config.launchProfiles, config.defaultLaunchProfileId);
|
|
42
|
+
if (defaultLaunchProfile) {
|
|
43
|
+
console.log(`Default launch profile: ${defaultLaunchProfile.label} (${formatLaunchProfileBehavior(defaultLaunchProfile)})`);
|
|
44
|
+
if (defaultLaunchProfile.unsafe) {
|
|
45
|
+
console.warn("Warning: Default launch profile uses danger-full-access.");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
console.log("Session mode: per Telegram context");
|
|
49
|
+
await writeConnectorState({
|
|
50
|
+
status: "ready",
|
|
51
|
+
pid: Number(process.env.NORDRELAY_WRAPPER_PID) || process.pid,
|
|
52
|
+
appPid: process.pid,
|
|
53
|
+
workspace: config.workspace,
|
|
54
|
+
sessionMode: "per Telegram context",
|
|
55
|
+
authenticated: authStatus.authenticated,
|
|
56
|
+
authMethod: authStatus.method,
|
|
57
|
+
codexCli: describeCodexCli(codexCli),
|
|
58
|
+
piCli: describePiCli(piCli),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
console.error(`Failed to start NordRelay: ${message}`);
|
|
64
|
+
await writeConnectorState({
|
|
65
|
+
status: "error",
|
|
66
|
+
pid: Number(process.env.NORDRELAY_WRAPPER_PID) || process.pid,
|
|
67
|
+
appPid: process.pid,
|
|
68
|
+
error: message,
|
|
69
|
+
});
|
|
70
|
+
registry?.disposeAll();
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
let shuttingDown = false;
|
|
74
|
+
const shutdown = (signal) => {
|
|
75
|
+
if (shuttingDown) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
shuttingDown = true;
|
|
79
|
+
console.log(`Received ${signal}, shutting down NordRelay...`);
|
|
80
|
+
if (bot)
|
|
81
|
+
bot.stop();
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
registry?.disposeAll();
|
|
84
|
+
void writeConnectorState({
|
|
85
|
+
status: "stopped",
|
|
86
|
+
pid: Number(process.env.NORDRELAY_WRAPPER_PID) || process.pid,
|
|
87
|
+
appPid: process.pid,
|
|
88
|
+
signal,
|
|
89
|
+
}).finally(() => {
|
|
90
|
+
console.log("NordRelay stopped.");
|
|
91
|
+
process.exit(0);
|
|
92
|
+
});
|
|
93
|
+
}, 500);
|
|
94
|
+
};
|
|
95
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
96
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
97
|
+
const MAX_RESTART_ATTEMPTS = 5;
|
|
98
|
+
const RESTART_DELAY_MS = 3000;
|
|
99
|
+
let restartAttempts = 0;
|
|
100
|
+
async function startPolling() {
|
|
101
|
+
try {
|
|
102
|
+
await bot.start({
|
|
103
|
+
drop_pending_updates: process.env.NORDRELAY_DROP_PENDING_UPDATES !== "0",
|
|
104
|
+
onStart: () => {
|
|
105
|
+
restartAttempts = 0;
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (shuttingDown) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
+
const is409 = message.includes("409") || message.includes("Conflict");
|
|
115
|
+
if (is409 && restartAttempts < MAX_RESTART_ATTEMPTS) {
|
|
116
|
+
restartAttempts += 1;
|
|
117
|
+
console.warn(`Polling error (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS}): ${message}`);
|
|
118
|
+
console.warn(`Restarting polling in ${RESTART_DELAY_MS / 1000}s...`);
|
|
119
|
+
await new Promise((resolve) => setTimeout(resolve, RESTART_DELAY_MS));
|
|
120
|
+
return startPolling();
|
|
121
|
+
}
|
|
122
|
+
console.error(`Fatal polling error: ${message}`);
|
|
123
|
+
registry?.disposeAll();
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
await startPolling();
|
|
128
|
+
async function writeConnectorState(payload) {
|
|
129
|
+
const stateFile = process.env.NORDRELAY_STATE_FILE;
|
|
130
|
+
if (!stateFile) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
await mkdir(path.dirname(stateFile), { recursive: true });
|
|
135
|
+
await writeFile(stateFile, `${JSON.stringify({ ...payload, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
console.warn("Failed to write connector state:", error instanceof Error ? error.message : String(error));
|
|
139
|
+
}
|
|
140
|
+
}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { redactUnknown } from "./redaction.js";
|
|
2
|
+
export function installConsoleLogger(format) {
|
|
3
|
+
if (format !== "json") {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
const targetLog = console.log.bind(console);
|
|
7
|
+
console.log = (...args) => {
|
|
8
|
+
targetLog(JSON.stringify(toLogRecord("info", args)));
|
|
9
|
+
};
|
|
10
|
+
console.warn = (...args) => {
|
|
11
|
+
targetLog(JSON.stringify(toLogRecord("warn", args)));
|
|
12
|
+
};
|
|
13
|
+
console.error = (...args) => {
|
|
14
|
+
targetLog(JSON.stringify(toLogRecord("error", args)));
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function toLogRecord(level, args) {
|
|
18
|
+
return {
|
|
19
|
+
ts: new Date().toISOString(),
|
|
20
|
+
level,
|
|
21
|
+
event: "console",
|
|
22
|
+
message: args.map(formatArg).join(" "),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function formatArg(value) {
|
|
26
|
+
return redactUnknown(value);
|
|
27
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, existsSync, openSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { describeCodexCli, resolveCodexCli } from "./codex-cli.js";
|
|
7
|
+
import { findLatestDatabase } from "./codex-state.js";
|
|
8
|
+
import { describePiCli, resolvePiCli } from "./pi-cli.js";
|
|
9
|
+
const APP_NAME = "nordrelay";
|
|
10
|
+
const DEFAULT_HOME = path.join(os.homedir(), ".codex", "nordrelay");
|
|
11
|
+
const SECRET_RE = /(bot|token|api[_-]?key|authorization|bearer|password|secret)(["'=: ]+)([^\s"',]+)/gi;
|
|
12
|
+
export function getConnectorHome() {
|
|
13
|
+
return process.env.NORDRELAY_HOME || DEFAULT_HOME;
|
|
14
|
+
}
|
|
15
|
+
export function getConnectorStatePath() {
|
|
16
|
+
return process.env.NORDRELAY_STATE_FILE || path.join(getConnectorHome(), "state.json");
|
|
17
|
+
}
|
|
18
|
+
export function getConnectorLogPath() {
|
|
19
|
+
return path.join(getConnectorHome(), "nordrelay.log");
|
|
20
|
+
}
|
|
21
|
+
export function getUpdateLogPath() {
|
|
22
|
+
return path.join(getConnectorHome(), "update.log");
|
|
23
|
+
}
|
|
24
|
+
export async function readConnectorState() {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(await readFile(getConnectorStatePath(), "utf8"));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function readLogTail(lines = 80, filePath = getConnectorLogPath()) {
|
|
33
|
+
const boundedLines = Math.min(Math.max(lines, 1), 300);
|
|
34
|
+
try {
|
|
35
|
+
const contents = await readFile(filePath, "utf8");
|
|
36
|
+
return redactSecrets(contents.split(/\r?\n/).slice(-boundedLines).join("\n").trim());
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return `Cannot read log: ${error instanceof Error ? error.message : String(error)}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function getPackageVersion() {
|
|
43
|
+
try {
|
|
44
|
+
const pkg = JSON.parse(await readFile(path.join(getSourceRoot(), "package.json"), "utf8"));
|
|
45
|
+
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return "unknown";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function getConnectorHealth() {
|
|
52
|
+
const state = await readConnectorState();
|
|
53
|
+
const version = await getPackageVersion();
|
|
54
|
+
const pidRunning = isProcessRunning(state.pid);
|
|
55
|
+
const appPidRunning = isProcessRunning(state.appPid);
|
|
56
|
+
return {
|
|
57
|
+
version,
|
|
58
|
+
state,
|
|
59
|
+
pidRunning,
|
|
60
|
+
appPidRunning,
|
|
61
|
+
codexCli: describeCodexCli(resolveCodexCli()),
|
|
62
|
+
piCli: describePiCli(resolvePiCli()),
|
|
63
|
+
stateFile: getConnectorStatePath(),
|
|
64
|
+
logFile: getConnectorLogPath(),
|
|
65
|
+
databasePath: findLatestDatabase(),
|
|
66
|
+
uptimeSeconds: Math.max(0, Math.round(process.uptime())),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function spawnConnectorRestart() {
|
|
70
|
+
const script = getWrapperScriptPath();
|
|
71
|
+
const child = spawn(process.execPath, [script, "restart", "--keep-pending-updates"], {
|
|
72
|
+
cwd: getSourceRoot(),
|
|
73
|
+
detached: true,
|
|
74
|
+
env: process.env,
|
|
75
|
+
stdio: "ignore",
|
|
76
|
+
});
|
|
77
|
+
child.unref();
|
|
78
|
+
}
|
|
79
|
+
export function spawnSelfUpdate() {
|
|
80
|
+
const sourceRoot = getSourceRoot();
|
|
81
|
+
const script = getWrapperScriptPath();
|
|
82
|
+
const updateLog = getUpdateLogPath();
|
|
83
|
+
const logFd = openSync(updateLog, "a");
|
|
84
|
+
const command = [
|
|
85
|
+
"set -e",
|
|
86
|
+
`printf '\\n[%s] Starting connector self-update\\n' "$(date -Is)"`,
|
|
87
|
+
"git pull --ff-only origin main",
|
|
88
|
+
"npm install",
|
|
89
|
+
"npm run check",
|
|
90
|
+
"npm test",
|
|
91
|
+
"npm run build",
|
|
92
|
+
`printf '[%s] Checks passed; restarting connector\\n' "$(date -Is)"`,
|
|
93
|
+
`${shellQuote(process.execPath)} ${shellQuote(script)} restart --keep-pending-updates`,
|
|
94
|
+
].join(" && ");
|
|
95
|
+
const child = spawn("sh", ["-lc", command], {
|
|
96
|
+
cwd: sourceRoot,
|
|
97
|
+
detached: true,
|
|
98
|
+
env: process.env,
|
|
99
|
+
stdio: ["ignore", logFd, logFd],
|
|
100
|
+
});
|
|
101
|
+
child.unref();
|
|
102
|
+
closeSync(logFd);
|
|
103
|
+
return updateLog;
|
|
104
|
+
}
|
|
105
|
+
export function getSourceRoot() {
|
|
106
|
+
return process.env.NORDRELAY_SOURCE_ROOT || process.cwd();
|
|
107
|
+
}
|
|
108
|
+
function getWrapperScriptPath() {
|
|
109
|
+
const sourceRoot = getSourceRoot();
|
|
110
|
+
const script = path.join(sourceRoot, "plugins", APP_NAME, "scripts", `${APP_NAME}.mjs`);
|
|
111
|
+
if (existsSync(script)) {
|
|
112
|
+
return script;
|
|
113
|
+
}
|
|
114
|
+
return path.join(process.cwd(), "plugins", APP_NAME, "scripts", `${APP_NAME}.mjs`);
|
|
115
|
+
}
|
|
116
|
+
function isProcessRunning(pid) {
|
|
117
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
process.kill(pid, 0);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function redactSecrets(text) {
|
|
129
|
+
return text.replace(SECRET_RE, "$1$2[redacted]");
|
|
130
|
+
}
|
|
131
|
+
function shellQuote(value) {
|
|
132
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
133
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function writeJsonFileAtomic(filePath, value) {
|
|
4
|
+
writeTextFileAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
5
|
+
}
|
|
6
|
+
export function writeTextFileAtomic(filePath, value) {
|
|
7
|
+
const dir = path.dirname(filePath);
|
|
8
|
+
mkdirSync(dir, { recursive: true });
|
|
9
|
+
const backupPath = `${filePath}.bak`;
|
|
10
|
+
const tempPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
11
|
+
try {
|
|
12
|
+
if (existsSync(filePath)) {
|
|
13
|
+
copyFileSync(filePath, backupPath);
|
|
14
|
+
}
|
|
15
|
+
writeFileSync(tempPath, value, "utf8");
|
|
16
|
+
renameSync(tempPath, filePath);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
rmSync(tempPath, { force: true });
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function readJsonFileWithBackup(filePath) {
|
|
24
|
+
const primary = readJsonFile(filePath);
|
|
25
|
+
if (primary.ok) {
|
|
26
|
+
return { value: primary.value, recoveredFromBackup: false };
|
|
27
|
+
}
|
|
28
|
+
const backupPath = `${filePath}.bak`;
|
|
29
|
+
const backup = readJsonFile(backupPath);
|
|
30
|
+
if (backup.ok) {
|
|
31
|
+
return {
|
|
32
|
+
value: backup.value,
|
|
33
|
+
recoveredFromBackup: true,
|
|
34
|
+
error: primary.error,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (primary.error && existsSync(filePath)) {
|
|
38
|
+
const corruptPath = `${filePath}.corrupt-${Date.now()}`;
|
|
39
|
+
try {
|
|
40
|
+
copyFileSync(filePath, corruptPath);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Best-effort only. The original file remains untouched.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
value: undefined,
|
|
48
|
+
recoveredFromBackup: false,
|
|
49
|
+
error: primary.error ?? backup.error,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function readJsonFile(filePath) {
|
|
53
|
+
if (!existsSync(filePath)) {
|
|
54
|
+
return { ok: false };
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
return { ok: true, value: JSON.parse(readFileSync(filePath, "utf8")) };
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: error instanceof Error ? error.message : String(error),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
package/dist/pi-cli.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { findExecutableOnPath } from "./codex-cli.js";
|
|
2
|
+
export function resolvePiCli(env = process.env, explicitPath) {
|
|
3
|
+
const configuredPath = optionalString(explicitPath) ?? optionalString(env.PI_CLI_PATH);
|
|
4
|
+
if (configuredPath) {
|
|
5
|
+
return { path: configuredPath, source: "env" };
|
|
6
|
+
}
|
|
7
|
+
const pathMatch = findExecutableOnPath("pi", env.PATH);
|
|
8
|
+
return pathMatch ? { path: pathMatch, source: "path" } : { source: "missing" };
|
|
9
|
+
}
|
|
10
|
+
export function describePiCli(resolution) {
|
|
11
|
+
if (resolution.path) {
|
|
12
|
+
return `${resolution.source} (${resolution.path})`;
|
|
13
|
+
}
|
|
14
|
+
return "missing";
|
|
15
|
+
}
|
|
16
|
+
function optionalString(value) {
|
|
17
|
+
const trimmed = value?.trim();
|
|
18
|
+
return trimmed ? trimmed : undefined;
|
|
19
|
+
}
|
package/dist/pi-rpc.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
|
+
export class PiRpcClient {
|
|
5
|
+
options;
|
|
6
|
+
child = null;
|
|
7
|
+
pending = new Map();
|
|
8
|
+
handlers = new Set();
|
|
9
|
+
stderrBuffer = "";
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.options = options;
|
|
12
|
+
}
|
|
13
|
+
updateOptions(options) {
|
|
14
|
+
this.options = { ...this.options, ...options };
|
|
15
|
+
}
|
|
16
|
+
onEvent(handler) {
|
|
17
|
+
this.handlers.add(handler);
|
|
18
|
+
return () => {
|
|
19
|
+
this.handlers.delete(handler);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async send(command, timeoutMs = 30_000) {
|
|
23
|
+
this.ensureStarted();
|
|
24
|
+
const child = this.child;
|
|
25
|
+
if (!child || child.stdin.destroyed) {
|
|
26
|
+
throw new Error("Pi RPC process is not available");
|
|
27
|
+
}
|
|
28
|
+
const id = typeof command.id === "string" ? command.id : `nr-${randomUUID()}`;
|
|
29
|
+
const payload = { ...command, id };
|
|
30
|
+
const response = await new Promise((resolve, reject) => {
|
|
31
|
+
const timeout = setTimeout(() => {
|
|
32
|
+
this.pending.delete(id);
|
|
33
|
+
reject(new Error(`Pi RPC command timed out: ${String(command.type ?? "unknown")}`));
|
|
34
|
+
}, timeoutMs);
|
|
35
|
+
timeout.unref?.();
|
|
36
|
+
this.pending.set(id, { resolve, reject, timeout });
|
|
37
|
+
child.stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
|
|
38
|
+
if (!error) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const pending = this.pending.get(id);
|
|
42
|
+
if (pending) {
|
|
43
|
+
clearTimeout(pending.timeout);
|
|
44
|
+
this.pending.delete(id);
|
|
45
|
+
pending.reject(error);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
if (!response.success) {
|
|
50
|
+
throw new Error(response.error || `Pi RPC command failed: ${String(command.type ?? "unknown")}`);
|
|
51
|
+
}
|
|
52
|
+
return response;
|
|
53
|
+
}
|
|
54
|
+
ensureStarted() {
|
|
55
|
+
if (this.child && !this.child.killed) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const args = ["--mode", "rpc"];
|
|
59
|
+
if (this.options.sessionDir) {
|
|
60
|
+
args.push("--session-dir", this.options.sessionDir);
|
|
61
|
+
}
|
|
62
|
+
if (this.options.sessionPath) {
|
|
63
|
+
args.push("--session", this.options.sessionPath);
|
|
64
|
+
}
|
|
65
|
+
if (this.options.model) {
|
|
66
|
+
args.push("--model", this.options.model);
|
|
67
|
+
}
|
|
68
|
+
if (this.options.thinking) {
|
|
69
|
+
args.push("--thinking", this.options.thinking);
|
|
70
|
+
}
|
|
71
|
+
const child = spawn(this.options.commandPath, args, {
|
|
72
|
+
cwd: this.options.cwd,
|
|
73
|
+
env: { ...process.env, ...this.options.env },
|
|
74
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
75
|
+
});
|
|
76
|
+
this.child = child;
|
|
77
|
+
this.stderrBuffer = "";
|
|
78
|
+
attachJsonlReader(child.stdout, (line) => this.handleLine(line));
|
|
79
|
+
child.stderr.on("data", (chunk) => {
|
|
80
|
+
this.stderrBuffer = appendWithLimit(this.stderrBuffer, typeof chunk === "string" ? chunk : chunk.toString("utf8"), 20_000);
|
|
81
|
+
});
|
|
82
|
+
child.on("error", (error) => {
|
|
83
|
+
this.rejectAll(error instanceof Error ? error : new Error(String(error)));
|
|
84
|
+
});
|
|
85
|
+
child.on("exit", (code, signal) => {
|
|
86
|
+
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
|
|
87
|
+
const stderr = this.stderrBuffer.trim();
|
|
88
|
+
this.rejectAll(new Error(`Pi RPC process exited (${reason})${stderr ? `: ${stderr}` : ""}`));
|
|
89
|
+
this.child = null;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
stop() {
|
|
93
|
+
const child = this.child;
|
|
94
|
+
this.child = null;
|
|
95
|
+
this.rejectAll(new Error("Pi RPC process stopped"));
|
|
96
|
+
child?.kill("SIGTERM");
|
|
97
|
+
}
|
|
98
|
+
handleLine(line) {
|
|
99
|
+
if (!line.trim()) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
let message;
|
|
103
|
+
try {
|
|
104
|
+
message = JSON.parse(line);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (message.type === "response" && typeof message.id === "string") {
|
|
110
|
+
const pending = this.pending.get(message.id);
|
|
111
|
+
if (pending) {
|
|
112
|
+
clearTimeout(pending.timeout);
|
|
113
|
+
this.pending.delete(message.id);
|
|
114
|
+
pending.resolve(message);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const handler of this.handlers) {
|
|
119
|
+
handler(message);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
rejectAll(error) {
|
|
123
|
+
for (const [id, pending] of this.pending.entries()) {
|
|
124
|
+
clearTimeout(pending.timeout);
|
|
125
|
+
pending.reject(error);
|
|
126
|
+
this.pending.delete(id);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function attachJsonlReader(stream, onLine) {
|
|
131
|
+
const decoder = new StringDecoder("utf8");
|
|
132
|
+
let buffer = "";
|
|
133
|
+
stream.on("data", (chunk) => {
|
|
134
|
+
buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
135
|
+
while (true) {
|
|
136
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
137
|
+
if (newlineIndex === -1) {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
let line = buffer.slice(0, newlineIndex);
|
|
141
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
142
|
+
if (line.endsWith("\r")) {
|
|
143
|
+
line = line.slice(0, -1);
|
|
144
|
+
}
|
|
145
|
+
onLine(line);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
stream.on("end", () => {
|
|
149
|
+
buffer += decoder.end();
|
|
150
|
+
if (buffer.length > 0) {
|
|
151
|
+
onLine(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function appendWithLimit(current, addition, limit) {
|
|
156
|
+
const next = `${current}${addition}`;
|
|
157
|
+
return next.length <= limit ? next : next.slice(next.length - limit);
|
|
158
|
+
}
|