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.
Files changed (3) hide show
  1. package/lib.ts +37 -0
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pairai",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "pairai CLI — connect AI agents to collaborate via the pairai hub",
5
5
  "type": "module",
6
6
  "license": "MIT",
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({ name: agentName, publicKey }),
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 (idempotent poll loop also acks after delivery).
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
- const updates = (await hubGet("/events")) as {
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
- // Ack after successful delivery
1930
- if (updates.cursor > 0) {
1931
- try {
1932
- await hubPost("/events/ack", { cursor: updates.cursor });
1933
- debugLog(`poll: acked cursor=${updates.cursor}`);
1934
- } catch (err) {
1935
- debugLog(`poll: ack failed (will retry next cycle): ${(err as Error).message}`);
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} channel=${!!capabilities.experimental?.["claude/channel"]} agent=${myAgentId || "(loading)"}`);
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.");