pairai 0.3.0 → 0.4.2

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 +80 -9
  2. package/package.json +5 -2
  3. package/pairai.ts +578 -105
package/lib.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Pure/testable functions extracted from pairai CLI.
3
- * Imported by both pairai.ts and unit tests.
3
+ * Imported by both pairai.ts, bridge, and unit tests.
4
4
  */
5
5
  import { existsSync, readFileSync, statSync, openSync, writeSync, closeSync, unlinkSync, mkdirSync, constants as fsConstants } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { homedir } from "node:os";
8
+ import {
9
+ publicEncrypt, privateDecrypt, sign, verify,
10
+ randomBytes, createCipheriv, createDecipheriv,
11
+ constants as cryptoConstants,
12
+ } from "node:crypto";
8
13
 
9
14
  export type Provider = "claude" | "gemini" | "cursor" | "copilot" | "windsurf" | "codex" | "amazonq";
10
15
 
@@ -23,16 +28,18 @@ export function validateProvider(value: string): Provider {
23
28
 
24
29
  /**
25
30
  * Auto-detect provider based on environment and filesystem.
31
+ * Returns null when detection is ambiguous (0 or 2+ matches).
26
32
  */
27
- export function detectProvider(): Provider {
33
+ export function detectProvider(): Provider | null {
28
34
  if (process.env.GEMINI_CLI) return "gemini";
29
- try { if (statSync(".cursor").isDirectory()) return "cursor"; } catch {}
30
- try { if (statSync(".windsurf").isDirectory()) return "windsurf"; } catch {}
31
- try { if (statSync(".vscode").isDirectory()) return "copilot"; } catch {}
32
- try { if (statSync(".codex").isDirectory()) return "codex"; } catch {}
33
- try { if (statSync(".amazonq").isDirectory()) return "amazonq"; } catch {}
34
- try { if (statSync(".gemini").isDirectory()) return "gemini"; } catch {}
35
- return "claude";
35
+ const found: Provider[] = [];
36
+ try { if (statSync(".cursor").isDirectory()) found.push("cursor"); } catch {}
37
+ try { if (statSync(".windsurf").isDirectory()) found.push("windsurf"); } catch {}
38
+ try { if (statSync(".vscode").isDirectory()) found.push("copilot"); } catch {}
39
+ try { if (statSync(".codex").isDirectory()) found.push("codex"); } catch {}
40
+ try { if (statSync(".amazonq").isDirectory()) found.push("amazonq"); } catch {}
41
+ try { if (statSync(".gemini").isDirectory()) found.push("gemini"); } catch {}
42
+ return found.length === 1 ? found[0] : null;
36
43
  }
37
44
 
38
45
  export interface ProviderConfig {
@@ -247,3 +254,67 @@ export function releaseLock(agentId: string, lockDir?: string): void {
247
254
  const path = lockPath(agentId, lockDir);
248
255
  try { unlinkSync(path); } catch {}
249
256
  }
257
+
258
+ // ── Crypto ──────────────────────────────────────────────────────────────────
259
+
260
+ /**
261
+ * Encrypt plaintext with AES-256-GCM, wrap key with RSA-OAEP for each recipient,
262
+ * sign (taskId + ciphertext) with RSA-PSS.
263
+ */
264
+ export function localEncrypt(
265
+ plaintext: string,
266
+ taskId: string,
267
+ senderPrivateKey: string,
268
+ recipientPubKeys: Record<string, string>,
269
+ ): { ciphertext: string; signature: string; encryptedKeys: Record<string, string> } {
270
+ const key = randomBytes(32);
271
+ const iv = randomBytes(12);
272
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
273
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
274
+ const tag = cipher.getAuthTag();
275
+ const ciphertext = Buffer.concat([iv, encrypted, tag]).toString("base64");
276
+
277
+ const signature = sign(null, Buffer.from(taskId + ciphertext), {
278
+ key: senderPrivateKey,
279
+ padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
280
+ saltLength: 32,
281
+ }).toString("base64");
282
+
283
+ const encryptedKeys: Record<string, string> = {};
284
+ for (const [id, pub] of Object.entries(recipientPubKeys)) {
285
+ encryptedKeys[id] = publicEncrypt(
286
+ { key: pub, oaepHash: "sha256", padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING },
287
+ key,
288
+ ).toString("base64");
289
+ }
290
+
291
+ return { ciphertext, signature, encryptedKeys };
292
+ }
293
+
294
+ /**
295
+ * Verify signature, unwrap AES key with own private key, decrypt AES-256-GCM.
296
+ */
297
+ export function localDecrypt(
298
+ ciphertext: string,
299
+ sig: string,
300
+ taskId: string,
301
+ senderPub: string,
302
+ myEncKey: string,
303
+ myPrivateKey: string,
304
+ ): string {
305
+ const valid = verify(null, Buffer.from(taskId + ciphertext), {
306
+ key: senderPub,
307
+ padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
308
+ saltLength: 32,
309
+ }, Buffer.from(sig, "base64"));
310
+ if (!valid) throw new Error("Signature verification failed");
311
+
312
+ const aesKey = privateDecrypt(
313
+ { key: myPrivateKey, oaepHash: "sha256", padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING },
314
+ Buffer.from(myEncKey, "base64"),
315
+ );
316
+ const data = Buffer.from(ciphertext, "base64");
317
+ const decipher = createDecipheriv("aes-256-gcm", aesKey, data.subarray(0, 12));
318
+ decipher.setAuthTag(data.subarray(-16));
319
+ return Buffer.concat([decipher.update(data.subarray(12, -16)), decipher.final()]).toString("utf8");
320
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "pairai",
3
- "version": "0.3.0",
3
+ "version": "0.4.2",
4
4
  "description": "pairai CLI — connect AI agents to collaborate via the pairai hub",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "homepage": "https://pairai.pro",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://git.quis.app/quis.app/connect_ai"
10
+ "url": "https://github.com/pairaipro/pairai",
11
+ "directory": "channel"
11
12
  },
12
13
  "keywords": [
13
14
  "ai",
@@ -28,6 +29,8 @@
28
29
  "README.md"
29
30
  ],
30
31
  "dependencies": {
32
+ "@inquirer/input": "^5.0.10",
33
+ "@inquirer/select": "^5.1.2",
31
34
  "@modelcontextprotocol/sdk": "^1.10.0",
32
35
  "nanoid": "^5.0.0",
33
36
  "tsx": "^4.0.0"
package/pairai.ts CHANGED
@@ -8,29 +8,55 @@
8
8
  * npx pairai upgrade — update to latest version (preserves keys and config)
9
9
  * npx pairai version — show current version
10
10
  *
11
- * Env: PAIRAI_HUB_URL, PAIRAI_AGENT_CRED, PAIRAI_KEY_FILE, PAIRAI_POLL_MS
11
+ * Env: PAIRAI_HUB_URL hub URL (default: https://pairai.pro)
12
+ * PAIRAI_AGENT_CRED — agent API key (from setup)
13
+ * PAIRAI_KEY_FILE — path to RSA private key .pem
14
+ * PAIRAI_POLL_MS — poll interval in ms (default: 5000)
15
+ * PAIRAI_LOCK_DIR — lock file directory (default: ~/.pairai/locks)
16
+ * PAIRAI_DEBUG — verbose log: "1" for ~/.pairai/debug.log, or a file path
12
17
  * Legacy: PAIRAI_URL, PAIRAI_API_KEY, PAIRAI_PRIVATE_KEY_PATH
13
18
  */
14
19
  import { execSync } from "node:child_process";
15
- import {
16
- generateKeyPairSync,
17
- publicEncrypt, privateDecrypt, sign, verify,
18
- randomBytes, createCipheriv, createDecipheriv, constants,
19
- } from "node:crypto";
20
- import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
20
+ import { generateKeyPairSync } from "node:crypto";
21
+ import { writeFileSync, mkdirSync, readFileSync, existsSync, appendFileSync } from "node:fs";
21
22
  import { homedir } from "node:os";
22
23
  import { join, dirname } from "node:path";
23
24
  import { fileURLToPath } from "node:url";
24
- import { validateProvider, detectProvider, checkExistingConfig, formatKeyBackupBox, acquireLock, releaseLock, getProviderConfig } from "./lib.js";
25
+ import { validateProvider, detectProvider, checkExistingConfig, formatKeyBackupBox, acquireLock, releaseLock, getProviderConfig, localEncrypt as _localEncrypt, localDecrypt as _localDecrypt } from "./lib.js";
25
26
  import type { Provider } from "./lib.js";
27
+ import select from "@inquirer/select";
28
+ import input from "@inquirer/input";
26
29
 
27
30
  const __dirname = dirname(fileURLToPath(import.meta.url));
28
31
  const PKG = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
29
32
  const VERSION: string = PKG.version;
30
33
 
34
+ const PROVIDER_CHOICES: { name: string; value: Provider }[] = [
35
+ { name: "Claude Code", value: "claude" },
36
+ { name: "Gemini CLI", value: "gemini" },
37
+ { name: "Cursor", value: "cursor" },
38
+ { name: "GitHub Copilot (VS Code)", value: "copilot" },
39
+ { name: "Windsurf", value: "windsurf" },
40
+ { name: "OpenAI Codex CLI", value: "codex" },
41
+ { name: "Amazon Q", value: "amazonq" },
42
+ ];
43
+
31
44
  const args = process.argv.slice(2);
32
45
  const command = args[0];
33
46
 
47
+ // ── Debug logging ────────────────────────────────────────────────────────────
48
+ // Enable with PAIRAI_DEBUG=1 or PAIRAI_DEBUG=/path/to/file.log
49
+ // When enabled, writes verbose poll/notification logs to the specified file
50
+ // (or ~/.pairai/debug.log if set to "1").
51
+ const DEBUG_LOG = process.env.PAIRAI_DEBUG;
52
+ const debugLogPath = DEBUG_LOG === "1" ? join(homedir(), ".pairai", "debug.log")
53
+ : DEBUG_LOG || null;
54
+ function debugLog(msg: string) {
55
+ if (!debugLogPath) return;
56
+ const line = `${new Date().toISOString()} ${msg}\n`;
57
+ try { appendFileSync(debugLogPath, line); } catch {}
58
+ }
59
+
34
60
  // ── Version ─────────────────────────────────────────────────────────────────
35
61
 
36
62
  if (command === "version" || args.includes("--version") || args.includes("-v")) {
@@ -72,6 +98,20 @@ if (command === "setup") {
72
98
  const rest = args.slice(1);
73
99
  const hubIdx = rest.indexOf("--hub");
74
100
  const hubUrl = hubIdx !== -1 ? rest.splice(hubIdx, 2)[1] : "https://pairai.pro";
101
+ try {
102
+ const parsed = new URL(hubUrl);
103
+ if (!["http:", "https:"].includes(parsed.protocol)) {
104
+ console.error(" Error: Hub URL must use http: or https: protocol.");
105
+ process.exit(1);
106
+ }
107
+ const isLocal = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1";
108
+ if (parsed.protocol === "http:" && !isLocal) {
109
+ console.error(" Warning: Hub URL uses HTTP (insecure). Use HTTPS for production deployments.");
110
+ }
111
+ } catch {
112
+ console.error(" Error: Invalid hub URL.");
113
+ process.exit(1);
114
+ }
75
115
  const providerIdx = rest.indexOf("--provider");
76
116
  const providerArg = providerIdx !== -1 ? rest.splice(providerIdx, 2)[1] : undefined;
77
117
  if (providerArg) {
@@ -80,16 +120,39 @@ if (command === "setup") {
80
120
  process.exit(1);
81
121
  }
82
122
  }
83
- const provider = (providerArg as Provider) ?? detectProvider();
123
+ let provider: Provider;
124
+ if (providerArg) {
125
+ provider = providerArg as Provider;
126
+ } else {
127
+ const detected = detectProvider();
128
+ if (detected) {
129
+ provider = detected;
130
+ } else if (process.stdin.isTTY) {
131
+ provider = await select({
132
+ message: "Select your AI tool:",
133
+ choices: PROVIDER_CHOICES,
134
+ });
135
+ } else {
136
+ console.error('Cannot auto-detect provider. Use --provider flag (e.g. npx pairai setup "My Agent" --provider cursor)');
137
+ process.exit(1);
138
+ }
139
+ }
84
140
  const globalIdx = rest.indexOf("--global");
85
141
  const useGlobal = globalIdx !== -1 ? (rest.splice(globalIdx, 1), true) : false;
86
- const agentName = rest.find((a) => !a.startsWith("--"));
142
+ let agentName = rest.find((a) => !a.startsWith("--"));
87
143
 
88
144
  const forceIdx = rest.indexOf("--force");
89
145
  const useForce = forceIdx !== -1 ? (rest.splice(forceIdx, 1), true) : false;
90
146
  if (!agentName) {
91
- console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
92
- process.exit(1);
147
+ if (process.stdin.isTTY) {
148
+ agentName = await input({
149
+ message: 'What should we call your agent? Other agents and users will see this name. (e.g. "Alice\'s Assistant", "Travel Bot")',
150
+ validate: (v) => v.trim().length > 0 && v.trim().length <= 64 ? true : "Name must be 1-64 characters",
151
+ });
152
+ } else {
153
+ console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
154
+ process.exit(1);
155
+ }
93
156
  }
94
157
 
95
158
  // Check for existing config to avoid accidental overwrites
@@ -129,7 +192,7 @@ if (command === "setup") {
129
192
  console.log(` API Key: ${apiKey}`);
130
193
 
131
194
  const keyDir = join(homedir(), ".pairai", "keys");
132
- mkdirSync(keyDir, { recursive: true });
195
+ mkdirSync(keyDir, { recursive: true, mode: 0o700 });
133
196
  const keyPath = join(keyDir, `${id}.pem`);
134
197
  writeFileSync(keyPath, privateKey, { mode: 0o600 });
135
198
  console.log(` Private key: ${keyPath}`);
@@ -149,7 +212,7 @@ if (command === "setup") {
149
212
  };
150
213
 
151
214
  // Ensure config directory exists
152
- mkdirSync(dirname(cfg.configPath), { recursive: true });
215
+ mkdirSync(dirname(cfg.configPath), { recursive: true, mode: 0o700 });
153
216
 
154
217
  if (cfg.format === "toml") {
155
218
  // Codex CLI uses TOML
@@ -165,14 +228,14 @@ if (command === "setup") {
165
228
  `PAIRAI_AGENT_CRED = "${apiKey}"`,
166
229
  `PAIRAI_KEY_FILE = "${keyPath}"`,
167
230
  ].join("\n");
168
- writeFileSync(cfg.configPath, existing + tomlBlock + "\n");
231
+ writeFileSync(cfg.configPath, existing + tomlBlock + "\n", { mode: 0o600 });
169
232
  } else {
170
233
  // JSON — merge with existing config
171
234
  let existing: any = {};
172
235
  try { if (existsSync(cfg.configPath)) existing = JSON.parse(readFileSync(cfg.configPath, "utf-8")); } catch {}
173
236
  if (!existing.mcpServers) existing.mcpServers = {};
174
237
  existing.mcpServers[cfg.mcpKey] = serverEntry;
175
- writeFileSync(cfg.configPath, JSON.stringify(existing, null, 2) + "\n");
238
+ writeFileSync(cfg.configPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
176
239
  }
177
240
 
178
241
  console.log(` Config: ${cfg.configPath}`);
@@ -200,6 +263,15 @@ if (command !== "serve") {
200
263
  console.error(" npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]");
201
264
  console.error(" npx pairai upgrade — update to latest version");
202
265
  console.error(" npx pairai version — show current version");
266
+ console.error("");
267
+ console.error("Environment variables:");
268
+ console.error(" PAIRAI_HUB_URL Hub URL (default: https://pairai.pro)");
269
+ console.error(" PAIRAI_AGENT_CRED Agent API key (from setup)");
270
+ console.error(" PAIRAI_KEY_FILE Path to RSA private key .pem file");
271
+ console.error(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
272
+ console.error(" PAIRAI_LOCK_DIR Lock file directory (default: ~/.pairai/locks)");
273
+ console.error(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
274
+ console.error(" PAIRAI_DEBUG=<path> Verbose log to custom file");
203
275
  process.exit(1);
204
276
  }
205
277
 
@@ -219,6 +291,16 @@ if (serveProviderArg) {
219
291
  const serveProvider = (serveProviderArg as Provider) ?? "claude";
220
292
 
221
293
  const HUB_URL = process.env.PAIRAI_HUB_URL ?? process.env.PAIRAI_URL ?? "https://pairai.pro";
294
+ try {
295
+ const parsedHub = new URL(HUB_URL);
296
+ if (!["http:", "https:"].includes(parsedHub.protocol)) {
297
+ console.error("[pairai] Error: Hub URL must use http: or https: protocol.");
298
+ process.exit(1);
299
+ }
300
+ } catch {
301
+ console.error("[pairai] Error: Invalid hub URL.");
302
+ process.exit(1);
303
+ }
222
304
  const API_KEY = process.env.PAIRAI_AGENT_CRED ?? process.env.PAIRAI_API_KEY;
223
305
  const POLL_MS = Number(process.env.PAIRAI_POLL_MS ?? "5000");
224
306
  const PRIVATE_KEY_PATH = process.env.PAIRAI_KEY_FILE ?? process.env.PAIRAI_PRIVATE_KEY_PATH;
@@ -236,29 +318,51 @@ const headers = {
236
318
 
237
319
  // ── Hub API ──────────────────────────────────────────────────────────────────
238
320
 
321
+ const HUB_TIMEOUT_MS = 30_000;
322
+
323
+ const API_PREFIX = "/api/v1";
324
+
239
325
  async function hubGet(path: string) {
240
- const res = await fetch(`${HUB_URL}${path}`, { headers });
326
+ const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, { headers, signal: AbortSignal.timeout(HUB_TIMEOUT_MS) });
241
327
  if (!res.ok) throw new Error(`GET ${path}: ${res.status}`);
242
328
  return res.json();
243
329
  }
244
330
 
245
331
  async function hubPost(path: string, body?: unknown) {
246
- const res = await fetch(`${HUB_URL}${path}`, {
332
+ const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
247
333
  method: "POST",
248
334
  headers: body ? headers : { Authorization: headers.Authorization },
249
335
  body: body ? JSON.stringify(body) : undefined,
336
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
250
337
  });
251
338
  if (!res.ok) throw new Error(`POST ${path}: ${res.status}`);
252
339
  return res.json();
253
340
  }
254
341
 
255
342
  async function hubPatch(path: string, body: unknown) {
256
- const res = await fetch(`${HUB_URL}${path}`, {
343
+ const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
257
344
  method: "PATCH",
258
345
  headers,
259
346
  body: JSON.stringify(body),
347
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
348
+ });
349
+ if (!res.ok) {
350
+ const body = await res.json().catch(() => ({})) as { error?: string };
351
+ throw new Error(body.error ?? `PATCH ${path}: ${res.status}`);
352
+ }
353
+ return res.json();
354
+ }
355
+
356
+ async function hubDelete(path: string) {
357
+ const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
358
+ method: "DELETE",
359
+ headers: { Authorization: headers.Authorization },
360
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
260
361
  });
261
- if (!res.ok) throw new Error(`PATCH ${path}: ${res.status}`);
362
+ if (!res.ok) {
363
+ const body = await res.json().catch(() => ({})) as { error?: string };
364
+ throw new Error(body.error ?? `DELETE ${path}: ${res.status}`);
365
+ }
262
366
  return res.json();
263
367
  }
264
368
 
@@ -273,7 +377,9 @@ async function loadAgentInfo() {
273
377
  const me = (await hubGet("/agents/me")) as { id: string; name: string; publicKey?: string };
274
378
  myAgentId = me.id;
275
379
  myPublicKey = me.publicKey ?? "";
276
- } catch {}
380
+ } catch (err) {
381
+ console.error("[pairai] failed to load agent info:", (err as Error).message);
382
+ }
277
383
  }
278
384
 
279
385
  async function loadPublicKeys() {
@@ -282,32 +388,14 @@ async function loadPublicKeys() {
282
388
  for (const c of conns) {
283
389
  if (c.publicKey) pubKeyCache.set(c.agentId, c.publicKey);
284
390
  }
285
- } catch {}
391
+ } catch (err) {
392
+ console.error("[pairai] failed to load public keys:", (err as Error).message);
393
+ }
286
394
  }
287
395
 
288
396
  function localEncrypt(plaintext: string, taskId: string, recipientPubKeys: Record<string, string>) {
289
397
  if (!PRIVATE_KEY) throw new Error("No private key configured");
290
- const key = randomBytes(32);
291
- const iv = randomBytes(12);
292
- const cipher = createCipheriv("aes-256-gcm", key, iv);
293
- const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
294
- const tag = cipher.getAuthTag();
295
- const ciphertext = Buffer.concat([iv, encrypted, tag]).toString("base64");
296
-
297
- const signature = sign(null, Buffer.from(taskId + ciphertext), {
298
- key: PRIVATE_KEY,
299
- padding: constants.RSA_PKCS1_PSS_PADDING,
300
- saltLength: 32,
301
- }).toString("base64");
302
-
303
- const encryptedKeys: Record<string, string> = {};
304
- for (const [id, pub] of Object.entries(recipientPubKeys)) {
305
- encryptedKeys[id] = publicEncrypt(
306
- { key: pub, oaepHash: "sha256", padding: constants.RSA_PKCS1_OAEP_PADDING },
307
- key,
308
- ).toString("base64");
309
- }
310
- return { ciphertext, signature, encryptedKeys };
398
+ return _localEncrypt(plaintext, taskId, PRIVATE_KEY, recipientPubKeys);
311
399
  }
312
400
 
313
401
  function localDecrypt(
@@ -318,21 +406,7 @@ function localDecrypt(
318
406
  myEncKey: string,
319
407
  ): string {
320
408
  if (!PRIVATE_KEY) throw new Error("No private key configured");
321
- const valid = verify(null, Buffer.from(taskId + ciphertext), {
322
- key: senderPub,
323
- padding: constants.RSA_PKCS1_PSS_PADDING,
324
- saltLength: 32,
325
- }, Buffer.from(sig, "base64"));
326
- if (!valid) throw new Error("Signature verification failed");
327
-
328
- const aesKey = privateDecrypt(
329
- { key: PRIVATE_KEY, oaepHash: "sha256", padding: constants.RSA_PKCS1_OAEP_PADDING },
330
- Buffer.from(myEncKey, "base64"),
331
- );
332
- const data = Buffer.from(ciphertext, "base64");
333
- const decipher = createDecipheriv("aes-256-gcm", aesKey, data.subarray(0, 12));
334
- decipher.setAuthTag(data.subarray(-16));
335
- return Buffer.concat([decipher.update(data.subarray(12, -16)), decipher.final()]).toString("utf8");
409
+ return _localDecrypt(ciphertext, sig, taskId, senderPub, myEncKey, PRIVATE_KEY);
336
410
  }
337
411
 
338
412
  // ── MCP Server ───────────────────────────────────────────────────────────────
@@ -340,6 +414,7 @@ function localDecrypt(
340
414
  const instructions = [
341
415
  "You are connected to the pairai agent hub. Messages from other AI agents arrive as notifications.",
342
416
  "The channel server polls for updates automatically — you don't need to poll manually.",
417
+ "When the user asks about updates, new messages, or pending work, use pairai_check_updates (not pairai_list_tasks).",
343
418
  "",
344
419
  "Notification attributes:",
345
420
  " task_id — the task this message belongs to",
@@ -369,7 +444,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
369
444
  tools: [
370
445
  {
371
446
  name: "pairai_check_updates",
372
- description: "Check for new tasks or unread messages. Call this at the start of every conversation and periodically during active collaborations.",
447
+ description: "Check for NEW incoming tasks and UNREAD messages from other agents. This is the primary way to discover what needs your attention — returns only unseen items, not all tasks. Call this first when asked about updates, new messages, or pending work.",
373
448
  inputSchema: {
374
449
  type: "object" as const,
375
450
  properties: {
@@ -441,6 +516,17 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
441
516
  required: ["code"],
442
517
  },
443
518
  },
519
+ {
520
+ name: "pairai_connect_directly",
521
+ description: "Connect directly to an agent that has auto-accept enabled — no pairing code needed. Use pairai_discover_agents to find agents with autoAccept: true.",
522
+ inputSchema: {
523
+ type: "object" as const,
524
+ properties: {
525
+ agent_id: { type: "string", description: "ID of the agent to connect with" },
526
+ },
527
+ required: ["agent_id"],
528
+ },
529
+ },
444
530
  {
445
531
  name: "pairai_update_profile",
446
532
  description: "Update your agent's profile — name, description, capabilities, and metadata. Returns the updated profile.",
@@ -452,6 +538,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
452
538
  capabilities: { type: "array", items: { type: "string" }, description: "List of capabilities, e.g. ['scheduling', 'code-review']" },
453
539
  metadata: { type: "object", description: "Arbitrary JSON metadata (max 4KB)" },
454
540
  discoverable: { type: "boolean", description: "Whether to appear in the public agent directory" },
541
+ autoAccept: { type: "boolean", description: "Whether to accept direct connections without pairing codes" },
455
542
  defaultApprovalRule: { type: "string", enum: ["auto", "require"], description: "Default approval rule for new connections" },
456
543
  },
457
544
  },
@@ -504,6 +591,17 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
504
591
  required: ["connection_id", "rule"],
505
592
  },
506
593
  },
594
+ {
595
+ name: "pairai_disconnect",
596
+ description: "Disconnect from an agent. Cascades: cancels active tasks, notifies the other agent.",
597
+ inputSchema: {
598
+ type: "object" as const,
599
+ properties: {
600
+ connection_id: { type: "string", description: "Connection ID to delete" },
601
+ },
602
+ required: ["connection_id"],
603
+ },
604
+ },
507
605
  {
508
606
  name: "pairai_list_pending_approvals",
509
607
  description: "List tasks waiting for your approval.",
@@ -534,7 +632,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
534
632
  },
535
633
  {
536
634
  name: "pairai_list_tasks",
537
- description: "List all tasks you are involved in (as initiator or target).",
635
+ description: "List ALL tasks (not just new ones). Use this to browse your full task history or filter by status. For checking what's new, use pairai_check_updates instead.",
538
636
  inputSchema: {
539
637
  type: "object" as const,
540
638
  properties: {
@@ -567,6 +665,18 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
567
665
  required: ["task_id", "filename", "mime_type", "base64_content"],
568
666
  },
569
667
  },
668
+ {
669
+ name: "pairai_download_file",
670
+ description: "Download a file from a task. For encrypted tasks, the file is automatically decrypted.",
671
+ inputSchema: {
672
+ type: "object" as const,
673
+ properties: {
674
+ task_id: { type: "string", description: "Task ID the file belongs to" },
675
+ file_id: { type: "string", description: "File ID to download" },
676
+ },
677
+ required: ["task_id", "file_id"],
678
+ },
679
+ },
570
680
  {
571
681
  name: "pairai_create_encrypted_task",
572
682
  description: "Create an encrypted task. Title and description are encrypted — the hub cannot read them.",
@@ -580,6 +690,78 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
580
690
  required: ["target_agent_id", "title"],
581
691
  },
582
692
  },
693
+ {
694
+ name: "pairai_delete_message",
695
+ description: "Delete (tombstone) a message you sent. The message content is replaced with [deleted].",
696
+ inputSchema: {
697
+ type: "object" as const,
698
+ properties: {
699
+ task_id: { type: "string", description: "Task ID" },
700
+ message_id: { type: "string", description: "Message ID to delete" },
701
+ },
702
+ required: ["task_id", "message_id"],
703
+ },
704
+ },
705
+ {
706
+ name: "pairai_delete_file",
707
+ description: "Delete a file you uploaded. Removes from disk and tombstones the associated message.",
708
+ inputSchema: {
709
+ type: "object" as const,
710
+ properties: {
711
+ file_id: { type: "string", description: "File ID to delete" },
712
+ },
713
+ required: ["file_id"],
714
+ },
715
+ },
716
+ {
717
+ name: "pairai_delete_task",
718
+ description: "Permanently delete a terminal task (completed, failed, cancelled) and all its messages and files.",
719
+ inputSchema: {
720
+ type: "object" as const,
721
+ properties: {
722
+ task_id: { type: "string", description: "Task ID to delete" },
723
+ },
724
+ required: ["task_id"],
725
+ },
726
+ },
727
+ {
728
+ name: "pairai_rotate_api_key",
729
+ description: "Generate a new API key. WARNING: old key immediately invalidated. Save the new key before doing anything else.",
730
+ inputSchema: {
731
+ type: "object" as const,
732
+ properties: {},
733
+ },
734
+ },
735
+ {
736
+ name: "pairai_delete_account",
737
+ description: "PERMANENTLY delete your agent and ALL associated data. IRREVERSIBLE.",
738
+ inputSchema: {
739
+ type: "object" as const,
740
+ properties: {},
741
+ },
742
+ },
743
+ {
744
+ name: "pairai_block_agent",
745
+ description: "Block an agent. They cannot discover or connect with you. Disconnects if connected.",
746
+ inputSchema: {
747
+ type: "object" as const,
748
+ properties: {
749
+ agent_id: { type: "string", description: "ID of the agent to block" },
750
+ },
751
+ required: ["agent_id"],
752
+ },
753
+ },
754
+ {
755
+ name: "pairai_unblock_agent",
756
+ description: "Unblock a previously blocked agent.",
757
+ inputSchema: {
758
+ type: "object" as const,
759
+ properties: {
760
+ agent_id: { type: "string", description: "ID of the agent to unblock" },
761
+ },
762
+ required: ["agent_id"],
763
+ },
764
+ },
583
765
  ],
584
766
  }));
585
767
 
@@ -593,6 +775,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
593
775
  hasUpdates: boolean;
594
776
  pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
595
777
  unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
778
+ cursor: number;
596
779
  };
597
780
 
598
781
  if (!updates.hasUpdates) {
@@ -628,8 +811,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
628
811
  parts.push(`**Unread messages:**\n${enriched.join("\n")}`);
629
812
  }
630
813
 
631
- if ((args as any).acknowledge) {
632
- await hubPost("/updates/ack");
814
+ // Always ack — this is the authoritative "user has seen these" signal.
815
+ // The poll loop does NOT ack; only this tool does.
816
+ if (updates.cursor > 0) {
817
+ await hubPost("/updates/ack", { cursor: updates.cursor });
633
818
  }
634
819
 
635
820
  return { content: [{ type: "text" as const, text: parts.join("\n\n") }] };
@@ -650,17 +835,22 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
650
835
  return { content: [{ type: "text" as const, text: "Error: Cannot reply to encrypted task — missing cryptographic keys. Re-run setup or reconnect." }] };
651
836
  }
652
837
  const envelope = JSON.stringify({ contentType: content_type ?? "text", body: text });
653
- const { ciphertext, signature, encryptedKeys } = localEncrypt(envelope, task_id, {
654
- [myAgentId]: myPublicKey,
655
- [otherId]: otherPub,
656
- });
657
- await hubPost(`/tasks/${task_id}/messages`, {
658
- content: ciphertext,
659
- contentType: "encrypted",
660
- encryptedKeys,
661
- senderSignature: signature,
662
- });
663
- return { content: [{ type: "text" as const, text: "Sent (encrypted)." }] };
838
+ try {
839
+ const { ciphertext, signature, encryptedKeys } = localEncrypt(envelope, task_id, {
840
+ [myAgentId]: myPublicKey,
841
+ [otherId]: otherPub,
842
+ });
843
+ await hubPost(`/tasks/${task_id}/messages`, {
844
+ content: ciphertext,
845
+ contentType: "encrypted",
846
+ encryptedKeys,
847
+ senderSignature: signature,
848
+ });
849
+ return { content: [{ type: "text" as const, text: "Sent (encrypted)." }] };
850
+ } catch (err) {
851
+ console.error(`[pairai] encryption failed for task ${task_id}: ${(err as Error).message}`);
852
+ return { content: [{ type: "text" as const, text: `Error: Failed to encrypt reply — ${(err as Error).message}. The other agent may have an invalid public key.` }], isError: true };
853
+ }
664
854
  }
665
855
  // Non-encrypted task: send plaintext
666
856
  await hubPost(`/tasks/${task_id}/messages`, {
@@ -671,8 +861,16 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
671
861
  }
672
862
 
673
863
  if (name === "pairai_update_status") {
674
- await hubPatch(`/tasks/${args.task_id}`, { status: args.status });
675
- return { content: [{ type: "text" as const, text: `Status → ${args.status}` }] };
864
+ try {
865
+ await hubPatch(`/tasks/${args.task_id}`, { status: args.status });
866
+ return { content: [{ type: "text" as const, text: `Status → ${args.status}` }] };
867
+ } catch (err) {
868
+ const msg = (err as Error).message;
869
+ if (msg.includes("409") || msg.includes("400")) {
870
+ return { content: [{ type: "text" as const, text: `Cannot update status — ${msg}` }] };
871
+ }
872
+ throw err;
873
+ }
676
874
  }
677
875
 
678
876
  if (name === "pairai_get_profile") {
@@ -734,6 +932,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
734
932
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
735
933
  }
736
934
 
935
+ if (name === "pairai_connect_directly") {
936
+ const { agent_id } = args as { agent_id: string };
937
+ const data = await hubPost(`/connect/${agent_id}`);
938
+ // Refresh public keys after new connection
939
+ await loadPublicKeys();
940
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
941
+ }
942
+
737
943
  if (name === "pairai_update_profile") {
738
944
  const body: Record<string, unknown> = {};
739
945
  if (args.name !== undefined) body.name = args.name;
@@ -741,6 +947,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
741
947
  if (args.capabilities !== undefined) body.capabilities = args.capabilities;
742
948
  if (args.metadata !== undefined) body.metadata = args.metadata;
743
949
  if (args.discoverable !== undefined) body.discoverable = args.discoverable === "true" || args.discoverable === true;
950
+ if (args.autoAccept !== undefined) body.autoAccept = args.autoAccept === "true" || args.autoAccept === true;
744
951
  if (args.defaultApprovalRule !== undefined) body.defaultApprovalRule = args.defaultApprovalRule;
745
952
  const data = await hubPatch("/agents/me", body);
746
953
  return { content: [{ type: "text" as const, text: "Profile updated.\n" + JSON.stringify(data, null, 2) }] };
@@ -777,6 +984,16 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
777
984
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
778
985
  }
779
986
 
987
+ if (name === "pairai_disconnect") {
988
+ const { connection_id } = args as { connection_id: string };
989
+ try {
990
+ const result = (await hubDelete(`/connections/${connection_id}`)) as { cancelledTasks?: number };
991
+ return { content: [{ type: "text" as const, text: `Disconnected. ${result.cancelledTasks ?? 0} task(s) cancelled.` }] };
992
+ } catch (err) {
993
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
994
+ }
995
+ }
996
+
780
997
  if (name === "pairai_list_pending_approvals") {
781
998
  const data = await hubGet("/approvals");
782
999
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
@@ -822,13 +1039,20 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
822
1039
  encryptedKeys?: any; senderSignature?: string;
823
1040
  }>;
824
1041
  if (data.encrypted) {
825
- const desc = decryptTaskDescription(data, data.id);
826
- data.description = desc;
1042
+ try {
1043
+ data.description = decryptTaskDescription(data, data.id);
1044
+ } catch {
1045
+ data.description = "[decryption failed]";
1046
+ }
827
1047
  }
828
1048
  const decryptedMsgs = msgs.map((m) => {
829
1049
  if (data.encrypted) {
830
- const d = decryptMessage(m, data.id);
831
- return { ...m, content: d.content, contentType: d.contentType };
1050
+ try {
1051
+ const d = decryptMessage(m, data.id);
1052
+ return { ...m, content: d.content, contentType: d.contentType };
1053
+ } catch {
1054
+ return { ...m, content: "[decryption failed]", contentType: "text" };
1055
+ }
832
1056
  }
833
1057
  return m;
834
1058
  });
@@ -839,12 +1063,151 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
839
1063
  const { task_id, filename, mime_type, base64_content } = args as {
840
1064
  task_id: string; filename: string; mime_type: string; base64_content: string;
841
1065
  };
1066
+
1067
+ // Check if task is encrypted
1068
+ const taskData = (await hubGet(`/tasks/${task_id}`)) as any;
1069
+ if (taskData.encrypted) {
1070
+ // Size guardrail: encryption overhead (~37%) could exceed hub's 50MB limit
1071
+ const rawSize = Buffer.from(base64_content, "base64").byteLength;
1072
+ if (rawSize > 28 * 1024 * 1024) {
1073
+ return { content: [{ type: "text" as const, text: "Error: File too large for encrypted upload (max ~28 MB before encryption overhead)." }] };
1074
+ }
1075
+
1076
+ await loadPublicKeys();
1077
+ const otherId =
1078
+ taskData.initiatorAgentId === myAgentId ? taskData.targetAgentId : taskData.initiatorAgentId;
1079
+ const otherPub = pubKeyCache.get(otherId);
1080
+ if (!otherPub || !myPublicKey || !PRIVATE_KEY) {
1081
+ return { content: [{ type: "text" as const, text: "Error: Cannot upload to encrypted task — missing cryptographic keys. Re-run setup or reconnect." }] };
1082
+ }
1083
+
1084
+ const envelope = JSON.stringify({ filename, mimeType: mime_type, data: base64_content });
1085
+ const { ciphertext, signature, encryptedKeys } = localEncrypt(envelope, task_id, {
1086
+ [myAgentId]: myPublicKey,
1087
+ [otherId]: otherPub,
1088
+ });
1089
+
1090
+ const data = await hubPost(`/tasks/${task_id}/files/json`, {
1091
+ filename: "encrypted_file",
1092
+ mimeType: "application/octet-stream",
1093
+ base64Content: ciphertext,
1094
+ encryptedKeys,
1095
+ senderSignature: signature,
1096
+ });
1097
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
1098
+ }
1099
+
1100
+ // Non-encrypted task: pass through
842
1101
  const data = await hubPost(`/tasks/${task_id}/files/json`, {
843
1102
  filename, mimeType: mime_type, base64Content: base64_content,
844
1103
  });
845
1104
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
846
1105
  }
847
1106
 
1107
+ if (name === "pairai_download_file") {
1108
+ const { task_id, file_id } = args as { task_id: string; file_id: string };
1109
+
1110
+ // Fetch the task to check encryption status
1111
+ const taskData = (await hubGet(`/tasks/${task_id}`)) as any;
1112
+
1113
+ // Download raw file bytes
1114
+ const response = await fetch(`${HUB_URL}/files/${file_id}`, {
1115
+ headers: { Authorization: `Bearer ${API_KEY}` },
1116
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
1117
+ });
1118
+ if (!response.ok) {
1119
+ return { content: [{ type: "text" as const, text: `Error: Failed to download file (${response.status}).` }] };
1120
+ }
1121
+ const fileBuffer = Buffer.from(await response.arrayBuffer());
1122
+
1123
+ if (!taskData.encrypted) {
1124
+ // Non-encrypted: return raw file info
1125
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
1126
+ const disposition = response.headers.get("content-disposition") ?? "";
1127
+ const filenameMatch = disposition.match(/filename="([^"]+)"/);
1128
+ return {
1129
+ content: [{
1130
+ type: "text" as const,
1131
+ text: JSON.stringify({
1132
+ filename: filenameMatch?.[1] ?? "file",
1133
+ mimeType: contentType,
1134
+ data: fileBuffer.toString("base64"),
1135
+ sizeBytes: fileBuffer.byteLength,
1136
+ }, null, 2),
1137
+ }],
1138
+ };
1139
+ }
1140
+
1141
+ // Encrypted task: find the message that holds the encryption keys for this file
1142
+ const taskMessages = (await hubGet(`/tasks/${task_id}/messages`)) as Array<{
1143
+ id: string; content: string; contentType: string; senderAgentId: string;
1144
+ encryptedKeys?: any; senderSignature?: string;
1145
+ }>;
1146
+ const fileMsg = taskMessages.find((m) => m.content === file_id);
1147
+ if (!fileMsg) {
1148
+ return { content: [{ type: "text" as const, text: "Error: Could not find message for this file." }] };
1149
+ }
1150
+
1151
+ // Legacy plaintext file in encrypted task (no keys stored)
1152
+ if (!fileMsg.encryptedKeys || !fileMsg.senderSignature) {
1153
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
1154
+ return {
1155
+ content: [{
1156
+ type: "text" as const,
1157
+ text: JSON.stringify({
1158
+ filename: "file",
1159
+ mimeType: contentType,
1160
+ data: fileBuffer.toString("base64"),
1161
+ sizeBytes: fileBuffer.byteLength,
1162
+ warning: "File was uploaded without encryption (legacy).",
1163
+ }, null, 2),
1164
+ }],
1165
+ };
1166
+ }
1167
+
1168
+ // Decrypt the file
1169
+ if (!PRIVATE_KEY) {
1170
+ return { content: [{ type: "text" as const, text: "Error: No private key configured. Re-run setup." }] };
1171
+ }
1172
+
1173
+ await loadPublicKeys();
1174
+ const senderPub = fileMsg.senderAgentId === myAgentId
1175
+ ? myPublicKey
1176
+ : pubKeyCache.get(fileMsg.senderAgentId);
1177
+ const keys = typeof fileMsg.encryptedKeys === "string"
1178
+ ? JSON.parse(fileMsg.encryptedKeys)
1179
+ : fileMsg.encryptedKeys;
1180
+ const myKey = keys[myAgentId];
1181
+
1182
+ if (!senderPub || !myKey) {
1183
+ return { content: [{ type: "text" as const, text: "Error: Decryption keys not found for this agent." }] };
1184
+ }
1185
+
1186
+ try {
1187
+ // The hub stores binary (decoded from base64 ciphertext); re-encode for localDecrypt
1188
+ const ciphertextB64 = fileBuffer.toString("base64");
1189
+ const plain = localDecrypt(ciphertextB64, fileMsg.senderSignature, task_id, senderPub, myKey);
1190
+ const envelope = JSON.parse(plain);
1191
+ return {
1192
+ content: [{
1193
+ type: "text" as const,
1194
+ text: JSON.stringify({
1195
+ filename: envelope.filename,
1196
+ mimeType: envelope.mimeType,
1197
+ data: envelope.data,
1198
+ sizeBytes: Buffer.from(envelope.data, "base64").byteLength,
1199
+ }, null, 2),
1200
+ }],
1201
+ };
1202
+ } catch (err) {
1203
+ const msg = (err as Error).message;
1204
+ if (msg.includes("Signature")) {
1205
+ return { content: [{ type: "text" as const, text: "Error: File signature verification failed — possible tampering." }] };
1206
+ }
1207
+ return { content: [{ type: "text" as const, text: `Error: Failed to decrypt file — ${msg}` }] };
1208
+ }
1209
+ }
1210
+
848
1211
  if (name === "pairai_create_encrypted_task") {
849
1212
  if (!PRIVATE_KEY)
850
1213
  return { content: [{ type: "text" as const, text: "No private key configured. Re-run setup." }] };
@@ -881,12 +1244,81 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
881
1244
  return { content: [{ type: "text" as const, text: `Encrypted task created. ID: ${taskId}` }] };
882
1245
  }
883
1246
 
1247
+ if (name === "pairai_delete_message") {
1248
+ const { task_id, message_id } = args as { task_id: string; message_id: string };
1249
+ try {
1250
+ await hubDelete(`/tasks/${task_id}/messages/${message_id}`);
1251
+ return { content: [{ type: "text" as const, text: "Message deleted." }] };
1252
+ } catch (err) {
1253
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1254
+ }
1255
+ }
1256
+
1257
+ if (name === "pairai_delete_file") {
1258
+ const { file_id } = args as { file_id: string };
1259
+ try {
1260
+ await hubDelete(`/files/${file_id}`);
1261
+ return { content: [{ type: "text" as const, text: "File deleted." }] };
1262
+ } catch (err) {
1263
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1264
+ }
1265
+ }
1266
+
1267
+ if (name === "pairai_delete_task") {
1268
+ const { task_id } = args as { task_id: string };
1269
+ try {
1270
+ const result = (await hubDelete(`/tasks/${task_id}`)) as { deletedMessages?: number; deletedFiles?: number };
1271
+ return { content: [{ type: "text" as const, text: `Task deleted. ${result.deletedMessages ?? 0} message(s) and ${result.deletedFiles ?? 0} file(s) removed.` }] };
1272
+ } catch (err) {
1273
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1274
+ }
1275
+ }
1276
+
1277
+ if (name === "pairai_rotate_api_key") {
1278
+ try {
1279
+ const result = (await hubPost("/agents/me/rotate-key")) as { apiKey: string };
1280
+ return { content: [{ type: "text" as const, text: `New API key: ${result.apiKey}\n\nWARNING: Your old key is now invalid. Save this key immediately — it will not be shown again.` }] };
1281
+ } catch (err) {
1282
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1283
+ }
1284
+ }
1285
+
1286
+ if (name === "pairai_delete_account") {
1287
+ try {
1288
+ await hubDelete("/agents/me");
1289
+ return { content: [{ type: "text" as const, text: "Account deleted." }] };
1290
+ } catch (err) {
1291
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1292
+ }
1293
+ }
1294
+
1295
+ if (name === "pairai_block_agent") {
1296
+ const { agent_id } = args as { agent_id: string };
1297
+ try {
1298
+ await hubPost("/agents/me/block", { agentId: agent_id });
1299
+ return { content: [{ type: "text" as const, text: "Agent blocked." }] };
1300
+ } catch (err) {
1301
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1302
+ }
1303
+ }
1304
+
1305
+ if (name === "pairai_unblock_agent") {
1306
+ const { agent_id } = args as { agent_id: string };
1307
+ try {
1308
+ await hubDelete(`/agents/me/block/${agent_id}`);
1309
+ return { content: [{ type: "text" as const, text: "Agent unblocked." }] };
1310
+ } catch (err) {
1311
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1312
+ }
1313
+ }
1314
+
884
1315
  throw new Error(`Unknown tool: ${name}`);
885
1316
  });
886
1317
 
887
1318
  // ── Polling ──────────────────────────────────────────────────────────────────
888
1319
 
889
1320
  const seenMessages = new Set<string>();
1321
+ const SEEN_MESSAGES_MAX = 10_000;
890
1322
 
891
1323
  function decryptMessage(
892
1324
  msg: { content: string; contentType: string; senderAgentId: string; encryptedKeys?: any; senderSignature?: string },
@@ -943,13 +1375,16 @@ async function poll() {
943
1375
  hasUpdates: boolean;
944
1376
  pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
945
1377
  unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
1378
+ cursor: number;
946
1379
  };
947
1380
 
1381
+ debugLog(`poll: hasUpdates=${updates.hasUpdates} tasks=${updates.pendingTasks.length} messages=${updates.unreadMessages.length} cursor=${updates.cursor}`);
948
1382
  if (!updates.hasUpdates) return;
1383
+ console.error(`[pairai] poll: ${updates.pendingTasks.length} tasks, ${updates.unreadMessages.length} messages`);
949
1384
 
950
1385
  for (const task of updates.pendingTasks) {
951
1386
  const key = `task:${task.id}`;
952
- if (seenMessages.has(key)) continue;
1387
+ if (seenMessages.has(key)) { debugLog(`skip seen task ${task.id}`); continue; }
953
1388
  seenMessages.add(key);
954
1389
 
955
1390
  const full = (await hubGet(`/tasks/${task.id}`)) as {
@@ -959,19 +1394,27 @@ async function poll() {
959
1394
  senderSignature?: string;
960
1395
  initiatorAgentId?: string;
961
1396
  approvalStatus?: string | null;
962
- messages: Array<{
963
- content: string;
964
- contentType: string;
965
- senderAgentId: string;
966
- encryptedKeys?: any;
967
- senderSignature?: string;
968
- }>;
969
1397
  };
1398
+ const taskMsgs = (await hubGet(`/tasks/${task.id}/messages`)) as Array<{
1399
+ content: string;
1400
+ contentType: string;
1401
+ senderAgentId: string;
1402
+ encryptedKeys?: any;
1403
+ senderSignature?: string;
1404
+ }>;
970
1405
 
971
1406
  const desc = decryptTaskDescription(full, task.id);
972
- const decryptedMessages = (full.messages ?? []).map((m) => {
973
- const d = decryptMessage(m, task.id);
974
- return d.content;
1407
+ const decryptedMessages = (taskMsgs ?? []).map((m) => {
1408
+ // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1409
+ if (m.contentType === "encrypted" && m.encryptedKeys && m.content.length < 30) {
1410
+ return "[File attachment — use pairai_download_file to retrieve]";
1411
+ }
1412
+ try {
1413
+ const d = decryptMessage(m, task.id);
1414
+ return d.content;
1415
+ } catch {
1416
+ return "[decryption failed]";
1417
+ }
975
1418
  });
976
1419
 
977
1420
  const isPendingApproval = full.approvalStatus === "pending";
@@ -997,24 +1440,34 @@ async function poll() {
997
1440
  }
998
1441
 
999
1442
  for (const unread of updates.unreadMessages) {
1000
- const full = (await hubGet(`/tasks/${unread.taskId}`)) as {
1001
- messages: Array<{
1002
- id: string;
1003
- content: string;
1004
- contentType: string;
1005
- senderAgentId: string;
1006
- encryptedKeys?: any;
1007
- senderSignature?: string;
1008
- }>;
1009
- };
1443
+ const msgs = (await hubGet(`/tasks/${unread.taskId}/messages`)) as Array<{
1444
+ id: string;
1445
+ content: string;
1446
+ contentType: string;
1447
+ senderAgentId: string;
1448
+ encryptedKeys?: any;
1449
+ senderSignature?: string;
1450
+ }>;
1010
1451
 
1011
- if (!full?.messages) continue;
1012
- for (const msg of full.messages.slice(-unread.count)) {
1452
+ debugLog(`unread: taskId=${unread.taskId} count=${unread.count} fetched=${msgs?.length ?? 0}`);
1453
+ if (!msgs || msgs.length === 0) continue;
1454
+ for (const msg of msgs.slice(-unread.count)) {
1013
1455
  const key = `msg:${msg.id}`;
1014
- if (seenMessages.has(key)) continue;
1456
+ if (seenMessages.has(key)) { debugLog(`skip seen msg ${msg.id}`); continue; }
1015
1457
  seenMessages.add(key);
1016
1458
 
1017
- const decrypted = decryptMessage(msg, unread.taskId);
1459
+ // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1460
+ const isEncryptedFile = msg.contentType === "encrypted" && msg.encryptedKeys && msg.content.length < 30;
1461
+ let decrypted: { content: string; contentType: string };
1462
+ if (isEncryptedFile) {
1463
+ decrypted = { content: "[File attachment — use pairai_download_file to retrieve]", contentType: "text" };
1464
+ } else {
1465
+ try {
1466
+ decrypted = decryptMessage(msg, unread.taskId);
1467
+ } catch {
1468
+ decrypted = { content: "[decryption failed]", contentType: "text" };
1469
+ }
1470
+ }
1018
1471
 
1019
1472
  try {
1020
1473
  await mcp.notification({
@@ -1037,9 +1490,20 @@ async function poll() {
1037
1490
  }
1038
1491
  }
1039
1492
 
1040
- await hubPost("/updates/ack");
1493
+ // Do NOT ack the hub here — the hub's lastSeenRowid is only advanced
1494
+ // when the user explicitly calls pairai_check_updates (authoritative ack).
1495
+ // The seenMessages Set prevents duplicate notifications within this session.
1496
+ debugLog(`poll: processed cursor=${updates.cursor} (hub NOT acked — seenMessages dedup only)`);
1497
+
1498
+ // Prevent unbounded memory growth
1499
+ if (seenMessages.size > SEEN_MESSAGES_MAX) {
1500
+ const excess = seenMessages.size - SEEN_MESSAGES_MAX;
1501
+ const iter = seenMessages.values();
1502
+ for (let i = 0; i < excess; i++) seenMessages.delete(iter.next().value!);
1503
+ }
1041
1504
  } catch (err) {
1042
1505
  console.error(`[pairai] poll error: ${(err as Error).message}`);
1506
+ debugLog(`poll error: ${(err as Error).message}`);
1043
1507
  }
1044
1508
  }
1045
1509
 
@@ -1066,6 +1530,15 @@ process.on("SIGINT", cleanupLock);
1066
1530
  process.on("beforeExit", cleanupLock);
1067
1531
  process.on("exit", cleanupLock);
1068
1532
 
1533
+ // Detect parent death — when the MCP host (Claude/Gemini) exits, stdin closes.
1534
+ // Without this, the process becomes an orphan reparented to systemd.
1535
+ process.stdin.on("end", () => {
1536
+ console.error("[pairai] stdin closed (parent exited). Shutting down.");
1537
+ cleanupLock();
1538
+ process.exit(0);
1539
+ });
1540
+ process.stdin.resume(); // ensure 'end' fires even if nothing reads stdin
1541
+
1069
1542
  console.error(`[pairai] agent=${myAgentId} keys=${pubKeyCache.size} polling every ${POLL_MS}ms`);
1070
1543
  setInterval(poll, POLL_MS);
1071
1544
  poll();