pairai 0.5.1 → 0.6.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/lib.ts +37 -0
- package/package.json +1 -1
- package/pairai.ts +140 -17
package/lib.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { homedir } from "node:os";
|
|
|
8
8
|
import {
|
|
9
9
|
publicEncrypt, privateDecrypt, sign, verify,
|
|
10
10
|
randomBytes, createCipheriv, createDecipheriv,
|
|
11
|
+
createHash,
|
|
11
12
|
constants as cryptoConstants,
|
|
12
13
|
} from "node:crypto";
|
|
13
14
|
|
|
@@ -302,6 +303,42 @@ export function localEncrypt(
|
|
|
302
303
|
/**
|
|
303
304
|
* Verify signature, unwrap AES key with own private key, decrypt AES-256-GCM.
|
|
304
305
|
*/
|
|
306
|
+
/**
|
|
307
|
+
* Solve a PoW challenge by finding a nonce such that
|
|
308
|
+
* SHA-256(challenge + nonce) has at least `difficulty` leading zero bits.
|
|
309
|
+
*/
|
|
310
|
+
export function solveChallenge(challenge: string, difficulty: number): string {
|
|
311
|
+
let nonce = 0;
|
|
312
|
+
while (true) {
|
|
313
|
+
const solution = nonce.toString(16).padStart(8, "0");
|
|
314
|
+
const hash = createHash("sha256").update(challenge + solution).digest();
|
|
315
|
+
let zeroBits = 0;
|
|
316
|
+
for (const byte of hash) {
|
|
317
|
+
if (byte === 0) { zeroBits += 8; continue; }
|
|
318
|
+
zeroBits += Math.clz32(byte) - 24;
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
if (zeroBits >= difficulty) return solution;
|
|
322
|
+
nonce++;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Fetch a PoW challenge from the hub and solve it.
|
|
328
|
+
* Returns { challenge, solution } or null if the hub doesn't support PoW.
|
|
329
|
+
*/
|
|
330
|
+
export async function solveHubChallenge(hubUrl: string): Promise<{ challenge: string; solution: string } | null> {
|
|
331
|
+
try {
|
|
332
|
+
const res = await fetch(`${hubUrl}/agents/challenge`);
|
|
333
|
+
if (!res.ok) return null;
|
|
334
|
+
const data = await res.json() as { challenge: string; difficulty: number };
|
|
335
|
+
const solution = solveChallenge(data.challenge, data.difficulty);
|
|
336
|
+
return { challenge: data.challenge, solution };
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
305
342
|
export function localDecrypt(
|
|
306
343
|
ciphertext: string,
|
|
307
344
|
sig: string,
|
package/package.json
CHANGED
package/pairai.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* PAIRAI_KEY_FILE — path to RSA private key .pem
|
|
15
15
|
* PAIRAI_POLL_MS — poll interval in ms (default: 5000)
|
|
16
16
|
* PAIRAI_LOCK_DIR — lock file directory (default: ~/.pairai/locks)
|
|
17
|
+
* PAIRAI_CHANNEL_NOTIFICATIONS — "1" = poll loop acks server cursor (for Claude with --channel)
|
|
17
18
|
* PAIRAI_DEBUG — verbose log: "1" for ~/.pairai/debug.log, or a file path
|
|
18
19
|
* Legacy: PAIRAI_URL, PAIRAI_API_KEY, PAIRAI_PRIVATE_KEY_PATH
|
|
19
20
|
*/
|
|
@@ -23,7 +24,7 @@ import { writeFileSync, mkdirSync, readFileSync, existsSync, appendFileSync, ope
|
|
|
23
24
|
import { homedir } from "node:os";
|
|
24
25
|
import { join, dirname, resolve as pathResolve, sep as pathSep, basename, extname } from "node:path";
|
|
25
26
|
import { fileURLToPath } from "node:url";
|
|
26
|
-
import { validateProvider, detectProvider, checkExistingConfig, formatKeyBackupBox, acquireLock, releaseLock, getProviderConfig, localEncrypt as _localEncrypt, localDecrypt as _localDecrypt } from "./lib.js";
|
|
27
|
+
import { validateProvider, detectProvider, checkExistingConfig, formatKeyBackupBox, acquireLock, releaseLock, getProviderConfig, localEncrypt as _localEncrypt, localDecrypt as _localDecrypt, solveHubChallenge } from "./lib.js";
|
|
27
28
|
import type { Provider } from "./lib.js";
|
|
28
29
|
import select from "@inquirer/select";
|
|
29
30
|
import input from "@inquirer/input";
|
|
@@ -96,6 +97,7 @@ if (command === "help" || args.includes("--help") || args.includes("-h")) {
|
|
|
96
97
|
console.log(" PAIRAI_AGENT_CRED Agent API key");
|
|
97
98
|
console.log(" PAIRAI_KEY_FILE Path to RSA private key .pem");
|
|
98
99
|
console.log(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
|
|
100
|
+
console.log(" PAIRAI_CHANNEL_NOTIFICATIONS=1 Poll acks cursor (Claude --channel)");
|
|
99
101
|
console.log(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
|
|
100
102
|
console.log("\nExamples:");
|
|
101
103
|
console.log(' npx pairai setup "My Assistant"');
|
|
@@ -415,10 +417,27 @@ if (command === "setup") {
|
|
|
415
417
|
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
416
418
|
});
|
|
417
419
|
|
|
420
|
+
// Solve PoW challenge (falls back gracefully if hub doesn't support PoW)
|
|
421
|
+
process.stdout.write(" Solving registration challenge...");
|
|
422
|
+
const startTime = Date.now();
|
|
423
|
+
const powResult = await solveHubChallenge(hubUrl);
|
|
424
|
+
if (powResult) {
|
|
425
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
426
|
+
console.log(` done (${elapsed}s)`);
|
|
427
|
+
} else {
|
|
428
|
+
console.log(" (skipped — hub may not require PoW)");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const registrationBody: Record<string, unknown> = { name: agentName, publicKey };
|
|
432
|
+
if (powResult) {
|
|
433
|
+
registrationBody.challenge = powResult.challenge;
|
|
434
|
+
registrationBody.solution = powResult.solution;
|
|
435
|
+
}
|
|
436
|
+
|
|
418
437
|
const res = await fetch(`${hubUrl}/agents`, {
|
|
419
438
|
method: "POST",
|
|
420
439
|
headers: { "Content-Type": "application/json" },
|
|
421
|
-
body: JSON.stringify(
|
|
440
|
+
body: JSON.stringify(registrationBody),
|
|
422
441
|
});
|
|
423
442
|
|
|
424
443
|
if (!res.ok) {
|
|
@@ -515,6 +534,7 @@ if (command !== "serve") {
|
|
|
515
534
|
console.error(" PAIRAI_KEY_FILE Path to RSA private key .pem file");
|
|
516
535
|
console.error(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
|
|
517
536
|
console.error(" PAIRAI_LOCK_DIR Lock file directory (default: ~/.pairai/locks)");
|
|
537
|
+
console.error(" PAIRAI_CHANNEL_NOTIFICATIONS=1 Poll acks cursor (Claude --channel)");
|
|
518
538
|
console.error(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
|
|
519
539
|
console.error(" PAIRAI_DEBUG=<path> Verbose log to custom file");
|
|
520
540
|
process.exit(1);
|
|
@@ -1057,6 +1077,14 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1057
1077
|
required: ["agent_id"],
|
|
1058
1078
|
},
|
|
1059
1079
|
},
|
|
1080
|
+
{
|
|
1081
|
+
name: "pairai_export_my_data",
|
|
1082
|
+
description: "Export all your data — profile, connections, tasks, messages, files, blocks, and events. Rate limited to one export per 10 minutes.",
|
|
1083
|
+
inputSchema: {
|
|
1084
|
+
type: "object" as const,
|
|
1085
|
+
properties: {},
|
|
1086
|
+
},
|
|
1087
|
+
},
|
|
1060
1088
|
],
|
|
1061
1089
|
}));
|
|
1062
1090
|
|
|
@@ -1128,9 +1156,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1128
1156
|
parts.push(`**Unread messages:**\n${enriched.join("\n")}`);
|
|
1129
1157
|
}
|
|
1130
1158
|
|
|
1131
|
-
// Ack
|
|
1159
|
+
// Ack server-side cursor. For channel-capable clients the poll loop also acks,
|
|
1160
|
+
// but for non-channel clients this is the only place the server cursor advances.
|
|
1132
1161
|
if (updates.cursor > 0) {
|
|
1133
1162
|
await hubPost("/events/ack", { cursor: updates.cursor });
|
|
1163
|
+
// Sync local poll cursor so we don't re-notify for these events
|
|
1164
|
+
if (!channelCapable && updates.cursor > lastNotifiedEventId) {
|
|
1165
|
+
lastNotifiedEventId = updates.cursor;
|
|
1166
|
+
}
|
|
1134
1167
|
}
|
|
1135
1168
|
|
|
1136
1169
|
return { content: [{ type: "text" as const, text: parts.join("\n\n") }] };
|
|
@@ -1356,7 +1389,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1356
1389
|
if (data.encrypted) {
|
|
1357
1390
|
try {
|
|
1358
1391
|
data.description = decryptTaskDescription(data, data.id);
|
|
1359
|
-
} catch {
|
|
1392
|
+
} catch (err) {
|
|
1393
|
+
console.error(`[pairai] [crypto] task description decryption failed for ${data.id}: ${(err as Error).message}`);
|
|
1360
1394
|
data.description = "[decryption failed]";
|
|
1361
1395
|
}
|
|
1362
1396
|
}
|
|
@@ -1369,7 +1403,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1369
1403
|
try {
|
|
1370
1404
|
const d = decryptMessage(m, data.id);
|
|
1371
1405
|
return { ...m, content: d.content, contentType: d.contentType };
|
|
1372
|
-
} catch {
|
|
1406
|
+
} catch (err) {
|
|
1407
|
+
console.error(`[pairai] [crypto] message decryption failed for task ${data.id}: ${(err as Error).message}`);
|
|
1373
1408
|
return { ...m, content: "[decryption failed]", contentType: "text" };
|
|
1374
1409
|
}
|
|
1375
1410
|
}
|
|
@@ -1715,6 +1750,15 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1715
1750
|
}
|
|
1716
1751
|
}
|
|
1717
1752
|
|
|
1753
|
+
if (name === "pairai_export_my_data") {
|
|
1754
|
+
try {
|
|
1755
|
+
const data = await hubGet("/agents/me/export");
|
|
1756
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
1757
|
+
} catch (err) {
|
|
1758
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1718
1762
|
throw new Error(`Unknown tool: ${name}`);
|
|
1719
1763
|
});
|
|
1720
1764
|
|
|
@@ -1821,7 +1865,8 @@ async function deliverEventNotification(event: {
|
|
|
1821
1865
|
try {
|
|
1822
1866
|
const d = decryptMessage(m, event.taskId!);
|
|
1823
1867
|
return d.content;
|
|
1824
|
-
} catch {
|
|
1868
|
+
} catch (err) {
|
|
1869
|
+
console.error(`[pairai] [crypto] message decryption failed for task ${event.taskId}: ${(err as Error).message}`);
|
|
1825
1870
|
return "[decryption failed]";
|
|
1826
1871
|
}
|
|
1827
1872
|
});
|
|
@@ -1868,7 +1913,8 @@ async function deliverEventNotification(event: {
|
|
|
1868
1913
|
} else {
|
|
1869
1914
|
try {
|
|
1870
1915
|
decrypted = decryptMessage(msg, event.taskId);
|
|
1871
|
-
} catch {
|
|
1916
|
+
} catch (err) {
|
|
1917
|
+
console.error(`[pairai] [crypto] message decryption failed for task ${event.taskId}: ${(err as Error).message}`);
|
|
1872
1918
|
decrypted = { content: "[decryption failed]", contentType: "text" };
|
|
1873
1919
|
}
|
|
1874
1920
|
}
|
|
@@ -1896,12 +1942,81 @@ async function deliverEventNotification(event: {
|
|
|
1896
1942
|
}
|
|
1897
1943
|
}
|
|
1898
1944
|
|
|
1945
|
+
// Detect whether the MCP client reliably surfaces channel notifications.
|
|
1946
|
+
// Walk the process tree to find Claude Code with --dangerously-load-development-channels server:pairai-channel.
|
|
1947
|
+
// Falls back to PAIRAI_CHANNEL_NOTIFICATIONS=1 env var (non-Linux or custom setups).
|
|
1948
|
+
const CHANNEL_FLAG = "--dangerously-load-development-channels";
|
|
1949
|
+
const CHANNEL_VALUE = "server:pairai-channel";
|
|
1950
|
+
|
|
1951
|
+
function detectChannelCapable(): boolean {
|
|
1952
|
+
if (process.env.PAIRAI_CHANNEL_NOTIFICATIONS === "1") {
|
|
1953
|
+
debugLog("detect-channel: PAIRAI_CHANNEL_NOTIFICATIONS=1 (env override)");
|
|
1954
|
+
return true;
|
|
1955
|
+
}
|
|
1956
|
+
if (process.platform !== "linux") {
|
|
1957
|
+
debugLog(`detect-channel: platform=${process.platform} (not linux, skipping /proc walk)`);
|
|
1958
|
+
return false;
|
|
1959
|
+
}
|
|
1960
|
+
try {
|
|
1961
|
+
let pid = String(process.ppid);
|
|
1962
|
+
debugLog(`detect-channel: starting walk from ppid=${pid}`);
|
|
1963
|
+
for (let i = 0; i < 10; i++) {
|
|
1964
|
+
const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf-8").split("\0").filter(Boolean);
|
|
1965
|
+
const bin = cmdline[0] ?? "";
|
|
1966
|
+
debugLog(`detect-channel: pid=${pid} bin=${bin} args=${JSON.stringify(cmdline.slice(1))}`);
|
|
1967
|
+
if (bin === "claude" || bin.endsWith("/claude")) {
|
|
1968
|
+
// Check for our specific channel: --flag server:pairai-channel or --flag=server:pairai-channel
|
|
1969
|
+
let found = false;
|
|
1970
|
+
const channelArgs: string[] = [];
|
|
1971
|
+
for (let j = 1; j < cmdline.length; j++) {
|
|
1972
|
+
const arg = cmdline[j]!;
|
|
1973
|
+
if (arg === CHANNEL_FLAG && cmdline[j + 1]) {
|
|
1974
|
+
channelArgs.push(cmdline[j + 1]!);
|
|
1975
|
+
if (cmdline[j + 1] === CHANNEL_VALUE) found = true;
|
|
1976
|
+
j++; // skip value
|
|
1977
|
+
} else if (arg.startsWith(`${CHANNEL_FLAG}=`)) {
|
|
1978
|
+
const val = arg.slice(CHANNEL_FLAG.length + 1);
|
|
1979
|
+
channelArgs.push(val);
|
|
1980
|
+
if (val === CHANNEL_VALUE) found = true;
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
debugLog(`detect-channel: found claude binary at pid=${pid}, channels=${JSON.stringify(channelArgs)}, looking for="${CHANNEL_VALUE}", match=${found}`);
|
|
1984
|
+
return found;
|
|
1985
|
+
}
|
|
1986
|
+
// Walk up: read ppid from /proc/<pid>/stat
|
|
1987
|
+
// Format: "pid (comm) state ppid ..." — comm can contain spaces/parens,
|
|
1988
|
+
// so find the LAST ")" to skip past it, then parse fields after it.
|
|
1989
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
1990
|
+
const afterComm = stat.slice(stat.lastIndexOf(")") + 2);
|
|
1991
|
+
const nextPid = afterComm.split(" ")[1]; // fields: state ppid ...
|
|
1992
|
+
debugLog(`detect-channel: pid=${pid} → ppid=${nextPid}`);
|
|
1993
|
+
if (!nextPid || nextPid === "1" || nextPid === "0") {
|
|
1994
|
+
debugLog(`detect-channel: reached process tree root (pid=${nextPid}), stopping`);
|
|
1995
|
+
break;
|
|
1996
|
+
}
|
|
1997
|
+
pid = nextPid;
|
|
1998
|
+
}
|
|
1999
|
+
debugLog("detect-channel: exhausted process tree without finding claude binary");
|
|
2000
|
+
} catch (err) {
|
|
2001
|
+
debugLog(`detect-channel: error walking /proc: ${(err as Error).message}`);
|
|
2002
|
+
}
|
|
2003
|
+
return false;
|
|
2004
|
+
}
|
|
2005
|
+
const channelCapable = detectChannelCapable();
|
|
2006
|
+
|
|
2007
|
+
// For non-channel clients: track the highest event ID we've notified for locally,
|
|
2008
|
+
// so we don't re-deliver on each poll cycle. Not used when channelCapable=true.
|
|
2009
|
+
let lastNotifiedEventId = 0;
|
|
2010
|
+
|
|
1899
2011
|
async function poll() {
|
|
1900
2012
|
try {
|
|
1901
2013
|
// Refresh public keys to pick up new connections
|
|
1902
2014
|
await loadPublicKeys();
|
|
1903
2015
|
|
|
1904
|
-
|
|
2016
|
+
// Non-channel clients: use local cursor to avoid re-notifying, but don't touch server cursor.
|
|
2017
|
+
// Channel clients: use server cursor (default behavior — omit after=).
|
|
2018
|
+
const afterQs = !channelCapable && lastNotifiedEventId > 0 ? `?after=${lastNotifiedEventId}` : "";
|
|
2019
|
+
const updates = (await hubGet(`/events${afterQs}`)) as {
|
|
1905
2020
|
events: Array<{
|
|
1906
2021
|
id: number;
|
|
1907
2022
|
type: string;
|
|
@@ -1914,7 +2029,7 @@ async function poll() {
|
|
|
1914
2029
|
hasMore: boolean;
|
|
1915
2030
|
};
|
|
1916
2031
|
|
|
1917
|
-
debugLog(`poll: ${updates.events.length} events, cursor=${updates.cursor}, hasMore=${updates.hasMore}`);
|
|
2032
|
+
debugLog(`poll: ${updates.events.length} events, cursor=${updates.cursor}, hasMore=${updates.hasMore}${channelCapable ? "" : `, localCursor=${lastNotifiedEventId}`}`);
|
|
1918
2033
|
|
|
1919
2034
|
if (updates.events.length === 0) return;
|
|
1920
2035
|
|
|
@@ -1926,14 +2041,20 @@ async function poll() {
|
|
|
1926
2041
|
}
|
|
1927
2042
|
}
|
|
1928
2043
|
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
2044
|
+
if (channelCapable) {
|
|
2045
|
+
// Channel clients: ack server-side — notifications are reliably delivered
|
|
2046
|
+
if (updates.cursor > 0) {
|
|
2047
|
+
try {
|
|
2048
|
+
await hubPost("/events/ack", { cursor: updates.cursor });
|
|
2049
|
+
debugLog(`poll: acked cursor=${updates.cursor}`);
|
|
2050
|
+
} catch (err) {
|
|
2051
|
+
debugLog(`poll: ack failed (will retry next cycle): ${(err as Error).message}`);
|
|
2052
|
+
}
|
|
1936
2053
|
}
|
|
2054
|
+
} else {
|
|
2055
|
+
// Non-channel clients: advance local cursor only, leave server cursor for check_updates
|
|
2056
|
+
lastNotifiedEventId = updates.cursor;
|
|
2057
|
+
debugLog(`poll: local cursor advanced to ${lastNotifiedEventId}`);
|
|
1937
2058
|
}
|
|
1938
2059
|
|
|
1939
2060
|
if (updates.hasMore) {
|
|
@@ -1948,7 +2069,9 @@ async function poll() {
|
|
|
1948
2069
|
// ── Start ────────────────────────────────────────────────────────────────────
|
|
1949
2070
|
|
|
1950
2071
|
await mcp.connect(new StdioServerTransport());
|
|
1951
|
-
console.error(`[pairai] connected. provider=${serveProvider}
|
|
2072
|
+
console.error(`[pairai] connected. provider=${serveProvider} channelNotifications=${channelCapable} agent=${myAgentId || "(loading)"}`);
|
|
2073
|
+
debugLog(`startup: provider=${serveProvider} channelCapable=${channelCapable} (${channelCapable ? "poll acks server cursor" : "poll uses local cursor, check_updates acks"})`);
|
|
2074
|
+
|
|
1952
2075
|
await loadAgentInfo();
|
|
1953
2076
|
if (!myAgentId) {
|
|
1954
2077
|
console.error("[pairai] failed to load agent info from hub. Cannot start polling.");
|