pairai 0.2.5 → 0.3.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 +253 -15
  2. package/package.json +3 -2
  3. package/pairai.ts +646 -192
package/pairai.ts CHANGED
@@ -3,25 +3,27 @@
3
3
  * pairai CLI — connect AI agents via the pairai hub
4
4
  *
5
5
  * Commands:
6
- * npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini] [--global] [--force]
7
- * npx pairai serve [--provider claude|gemini]
6
+ * npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]
7
+ * npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]
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, statSync, 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, updateVersionInConfig, checkExistingConfig, formatKeyBackupBox } from "./lib.js";
25
+ import { validateProvider, detectProvider, checkExistingConfig, formatKeyBackupBox, acquireLock, releaseLock, getProviderConfig, localEncrypt as _localEncrypt, localDecrypt as _localDecrypt } from "./lib.js";
26
+ import type { Provider } from "./lib.js";
25
27
 
26
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
29
  const PKG = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
@@ -30,6 +32,19 @@ const VERSION: string = PKG.version;
30
32
  const args = process.argv.slice(2);
31
33
  const command = args[0];
32
34
 
35
+ // ── Debug logging ────────────────────────────────────────────────────────────
36
+ // Enable with PAIRAI_DEBUG=1 or PAIRAI_DEBUG=/path/to/file.log
37
+ // When enabled, writes verbose poll/notification logs to the specified file
38
+ // (or ~/.pairai/debug.log if set to "1").
39
+ const DEBUG_LOG = process.env.PAIRAI_DEBUG;
40
+ const debugLogPath = DEBUG_LOG === "1" ? join(homedir(), ".pairai", "debug.log")
41
+ : DEBUG_LOG || null;
42
+ function debugLog(msg: string) {
43
+ if (!debugLogPath) return;
44
+ const line = `${new Date().toISOString()} ${msg}\n`;
45
+ try { appendFileSync(debugLogPath, line); } catch {}
46
+ }
47
+
33
48
  // ── Version ─────────────────────────────────────────────────────────────────
34
49
 
35
50
  if (command === "version" || args.includes("--version") || args.includes("-v")) {
@@ -49,27 +64,11 @@ if (command === "upgrade") {
49
64
  } else {
50
65
  console.log(` New version available: v${latest}`);
51
66
  console.log(` Upgrading...\n`);
67
+ // Clear npx cache so next `npx pairai serve` picks up the new version
68
+ try { execSync("npx clear-npx-cache 2>/dev/null || rm -rf " + join(homedir(), ".npm/_npx"), { stdio: "pipe" }); } catch {}
52
69
  execSync("npm install -g pairai@latest", { stdio: "inherit" });
53
70
  console.log(`\n Upgraded to v${latest}.`);
54
71
  console.log(` Keys and config are unchanged.\n`);
55
-
56
- // Update pinned version in config files
57
- const configPaths = [
58
- join(process.cwd(), ".mcp.json"),
59
- join(process.cwd(), ".gemini", "settings.json"),
60
- join(homedir(), ".gemini", "settings.json"),
61
- ];
62
- for (const p of configPaths) {
63
- try {
64
- if (!existsSync(p)) continue;
65
- const content = readFileSync(p, "utf-8");
66
- const updated = updateVersionInConfig(content, latest);
67
- if (updated !== content) {
68
- writeFileSync(p, updated);
69
- console.log(` Updated version in ${p}`);
70
- }
71
- } catch {}
72
- }
73
72
  }
74
73
  } catch (err) {
75
74
  console.error(` Upgrade failed: ${(err as Error).message}`);
@@ -78,7 +77,7 @@ if (command === "upgrade") {
78
77
  process.exit(0);
79
78
  }
80
79
 
81
- // detectProvider, validateProvider, updateVersionInConfig, checkExistingConfig,
80
+ // detectProvider, validateProvider, checkExistingConfig,
82
81
  // formatKeyBackupBox are imported from ./lib.js
83
82
 
84
83
  // ── Setup: register + configure ──────────────────────────────────────────────
@@ -87,6 +86,20 @@ if (command === "setup") {
87
86
  const rest = args.slice(1);
88
87
  const hubIdx = rest.indexOf("--hub");
89
88
  const hubUrl = hubIdx !== -1 ? rest.splice(hubIdx, 2)[1] : "https://pairai.pro";
89
+ try {
90
+ const parsed = new URL(hubUrl);
91
+ if (!["http:", "https:"].includes(parsed.protocol)) {
92
+ console.error(" Error: Hub URL must use http: or https: protocol.");
93
+ process.exit(1);
94
+ }
95
+ const isLocal = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1";
96
+ if (parsed.protocol === "http:" && !isLocal) {
97
+ console.error(" Warning: Hub URL uses HTTP (insecure). Use HTTPS for production deployments.");
98
+ }
99
+ } catch {
100
+ console.error(" Error: Invalid hub URL.");
101
+ process.exit(1);
102
+ }
90
103
  const providerIdx = rest.indexOf("--provider");
91
104
  const providerArg = providerIdx !== -1 ? rest.splice(providerIdx, 2)[1] : undefined;
92
105
  if (providerArg) {
@@ -95,7 +108,7 @@ if (command === "setup") {
95
108
  process.exit(1);
96
109
  }
97
110
  }
98
- const provider = (providerArg as "claude" | "gemini") ?? detectProvider();
111
+ const provider = (providerArg as Provider) ?? detectProvider();
99
112
  const globalIdx = rest.indexOf("--global");
100
113
  const useGlobal = globalIdx !== -1 ? (rest.splice(globalIdx, 1), true) : false;
101
114
  const agentName = rest.find((a) => !a.startsWith("--"));
@@ -103,7 +116,7 @@ if (command === "setup") {
103
116
  const forceIdx = rest.indexOf("--force");
104
117
  const useForce = forceIdx !== -1 ? (rest.splice(forceIdx, 1), true) : false;
105
118
  if (!agentName) {
106
- console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini] [--global] [--force]');
119
+ console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
107
120
  process.exit(1);
108
121
  }
109
122
 
@@ -144,7 +157,7 @@ if (command === "setup") {
144
157
  console.log(` API Key: ${apiKey}`);
145
158
 
146
159
  const keyDir = join(homedir(), ".pairai", "keys");
147
- mkdirSync(keyDir, { recursive: true });
160
+ mkdirSync(keyDir, { recursive: true, mode: 0o700 });
148
161
  const keyPath = join(keyDir, `${id}.pem`);
149
162
  writeFileSync(keyPath, privateKey, { mode: 0o600 });
150
163
  console.log(` Private key: ${keyPath}`);
@@ -152,61 +165,54 @@ if (command === "setup") {
152
165
  for (const line of formatKeyBackupBox(keyPath)) console.log(line);
153
166
  console.log();
154
167
 
155
- if (provider === "gemini") {
156
- // Write .gemini/settings.json (project or global)
157
- const geminiDir = useGlobal
158
- ? join(homedir(), ".gemini")
159
- : join(process.cwd(), ".gemini");
160
- mkdirSync(geminiDir, { recursive: true });
161
- const settingsPath = join(geminiDir, "settings.json");
162
-
163
- // Merge with existing settings
168
+ const cfg = getProviderConfig(provider, process.cwd(), homedir(), useGlobal);
169
+ const serverEntry = {
170
+ command: "npx",
171
+ args: ["pairai", "serve"],
172
+ env: {
173
+ PAIRAI_HUB_URL: hubUrl,
174
+ PAIRAI_AGENT_CRED: apiKey,
175
+ PAIRAI_KEY_FILE: keyPath,
176
+ },
177
+ };
178
+
179
+ // Ensure config directory exists
180
+ mkdirSync(dirname(cfg.configPath), { recursive: true, mode: 0o700 });
181
+
182
+ if (cfg.format === "toml") {
183
+ // Codex CLI uses TOML
184
+ let existing = "";
185
+ try { if (existsSync(cfg.configPath)) existing = readFileSync(cfg.configPath, "utf-8"); } catch {}
186
+ const tomlBlock = [
187
+ `\n[mcp_servers.${cfg.mcpKey}]`,
188
+ `command = "npx"`,
189
+ `args = ["pairai", "serve"]`,
190
+ ``,
191
+ `[mcp_servers.${cfg.mcpKey}.env]`,
192
+ `PAIRAI_HUB_URL = "${hubUrl}"`,
193
+ `PAIRAI_AGENT_CRED = "${apiKey}"`,
194
+ `PAIRAI_KEY_FILE = "${keyPath}"`,
195
+ ].join("\n");
196
+ writeFileSync(cfg.configPath, existing + tomlBlock + "\n", { mode: 0o600 });
197
+ } else {
198
+ // JSON — merge with existing config
164
199
  let existing: any = {};
165
- try {
166
- if (existsSync(settingsPath)) {
167
- existing = JSON.parse(readFileSync(settingsPath, "utf-8"));
168
- }
169
- } catch {}
170
-
200
+ try { if (existsSync(cfg.configPath)) existing = JSON.parse(readFileSync(cfg.configPath, "utf-8")); } catch {}
171
201
  if (!existing.mcpServers) existing.mcpServers = {};
172
- existing.mcpServers.pairai = {
173
- command: "npx",
174
- args: [`pairai@${VERSION}`, "serve"],
175
- env: {
176
- PAIRAI_HUB_URL: hubUrl,
177
- PAIRAI_AGENT_CRED: apiKey,
178
- PAIRAI_KEY_FILE: keyPath,
179
- },
180
- };
181
-
182
- writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + "\n");
183
- console.log(` Config: ${settingsPath}`);
184
- console.log();
185
- console.log(` Next steps:`);
186
- console.log(` 1. Restart Gemini CLI to activate the pairai MCP server`);
187
- console.log(` 2. Ask Gemini: "Generate a pairing code"`);
188
- console.log(` 3. Share the code with another agent to connect`);
189
- } else {
190
- // Write .mcp.json for Claude Code
191
- const mcpConfig = {
192
- mcpServers: {
193
- "pairai-channel": {
194
- command: "npx",
195
- args: [`pairai@${VERSION}`, "serve"],
196
- env: { PAIRAI_AGENT_CRED: apiKey, PAIRAI_HUB_URL: hubUrl, PAIRAI_KEY_FILE: keyPath },
197
- },
198
- },
199
- };
202
+ existing.mcpServers[cfg.mcpKey] = serverEntry;
203
+ writeFileSync(cfg.configPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
204
+ }
200
205
 
201
- const cwd = process.cwd();
202
- const mcpJsonPath = join(cwd, ".mcp.json");
203
- writeFileSync(mcpJsonPath, JSON.stringify(mcpConfig, null, 2) + "\n");
204
- console.log(` Config: ${mcpJsonPath}`);
206
+ console.log(` Config: ${cfg.configPath}`);
207
+ console.log();
208
+ console.log(` Next steps:`);
209
+ console.log(` 1. ${cfg.instruction}`);
210
+ console.log(` 2. Ask your AI: "Generate a pairing code"`);
211
+ console.log(` 3. Share the code with another agent to connect`);
212
+ if (provider === "claude") {
205
213
  console.log();
206
- console.log(` Next steps:`);
207
- console.log(` 1. Start Claude Code in this directory`);
208
- console.log(` 2. Ask Claude: "Generate a pairing code"`);
209
- console.log(` 3. Share the code with another agent to connect`);
214
+ console.log(` Optional: Enable real-time notifications (research preview):`);
215
+ console.log(` claude --dangerously-load-development-channels`);
210
216
  }
211
217
 
212
218
  console.log();
@@ -218,10 +224,19 @@ if (command === "setup") {
218
224
  if (command !== "serve") {
219
225
  console.error(`pairai v${VERSION}\n`);
220
226
  console.error("Usage:");
221
- console.error(' npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini] [--global] [--force]');
222
- console.error(" npx pairai serve [--provider claude|gemini]");
227
+ console.error(' npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
228
+ console.error(" npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]");
223
229
  console.error(" npx pairai upgrade — update to latest version");
224
230
  console.error(" npx pairai version — show current version");
231
+ console.error("");
232
+ console.error("Environment variables:");
233
+ console.error(" PAIRAI_HUB_URL Hub URL (default: https://pairai.pro)");
234
+ console.error(" PAIRAI_AGENT_CRED Agent API key (from setup)");
235
+ console.error(" PAIRAI_KEY_FILE Path to RSA private key .pem file");
236
+ console.error(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
237
+ console.error(" PAIRAI_LOCK_DIR Lock file directory (default: ~/.pairai/locks)");
238
+ console.error(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
239
+ console.error(" PAIRAI_DEBUG=<path> Verbose log to custom file");
225
240
  process.exit(1);
226
241
  }
227
242
 
@@ -238,9 +253,19 @@ if (serveProviderArg) {
238
253
  process.exit(1);
239
254
  }
240
255
  }
241
- const serveProvider = (serveProviderArg as "claude" | "gemini") ?? "claude";
256
+ const serveProvider = (serveProviderArg as Provider) ?? "claude";
242
257
 
243
258
  const HUB_URL = process.env.PAIRAI_HUB_URL ?? process.env.PAIRAI_URL ?? "https://pairai.pro";
259
+ try {
260
+ const parsedHub = new URL(HUB_URL);
261
+ if (!["http:", "https:"].includes(parsedHub.protocol)) {
262
+ console.error("[pairai] Error: Hub URL must use http: or https: protocol.");
263
+ process.exit(1);
264
+ }
265
+ } catch {
266
+ console.error("[pairai] Error: Invalid hub URL.");
267
+ process.exit(1);
268
+ }
244
269
  const API_KEY = process.env.PAIRAI_AGENT_CRED ?? process.env.PAIRAI_API_KEY;
245
270
  const POLL_MS = Number(process.env.PAIRAI_POLL_MS ?? "5000");
246
271
  const PRIVATE_KEY_PATH = process.env.PAIRAI_KEY_FILE ?? process.env.PAIRAI_PRIVATE_KEY_PATH;
@@ -258,29 +283,51 @@ const headers = {
258
283
 
259
284
  // ── Hub API ──────────────────────────────────────────────────────────────────
260
285
 
286
+ const HUB_TIMEOUT_MS = 30_000;
287
+
288
+ const API_PREFIX = "/api/v1";
289
+
261
290
  async function hubGet(path: string) {
262
- const res = await fetch(`${HUB_URL}${path}`, { headers });
291
+ const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, { headers, signal: AbortSignal.timeout(HUB_TIMEOUT_MS) });
263
292
  if (!res.ok) throw new Error(`GET ${path}: ${res.status}`);
264
293
  return res.json();
265
294
  }
266
295
 
267
296
  async function hubPost(path: string, body?: unknown) {
268
- const res = await fetch(`${HUB_URL}${path}`, {
297
+ const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
269
298
  method: "POST",
270
299
  headers: body ? headers : { Authorization: headers.Authorization },
271
300
  body: body ? JSON.stringify(body) : undefined,
301
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
272
302
  });
273
303
  if (!res.ok) throw new Error(`POST ${path}: ${res.status}`);
274
304
  return res.json();
275
305
  }
276
306
 
277
307
  async function hubPatch(path: string, body: unknown) {
278
- const res = await fetch(`${HUB_URL}${path}`, {
308
+ const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
279
309
  method: "PATCH",
280
310
  headers,
281
311
  body: JSON.stringify(body),
312
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
313
+ });
314
+ if (!res.ok) {
315
+ const body = await res.json().catch(() => ({})) as { error?: string };
316
+ throw new Error(body.error ?? `PATCH ${path}: ${res.status}`);
317
+ }
318
+ return res.json();
319
+ }
320
+
321
+ async function hubDelete(path: string) {
322
+ const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
323
+ method: "DELETE",
324
+ headers: { Authorization: headers.Authorization },
325
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
282
326
  });
283
- if (!res.ok) throw new Error(`PATCH ${path}: ${res.status}`);
327
+ if (!res.ok) {
328
+ const body = await res.json().catch(() => ({})) as { error?: string };
329
+ throw new Error(body.error ?? `DELETE ${path}: ${res.status}`);
330
+ }
284
331
  return res.json();
285
332
  }
286
333
 
@@ -309,27 +356,7 @@ async function loadPublicKeys() {
309
356
 
310
357
  function localEncrypt(plaintext: string, taskId: string, recipientPubKeys: Record<string, string>) {
311
358
  if (!PRIVATE_KEY) throw new Error("No private key configured");
312
- const key = randomBytes(32);
313
- const iv = randomBytes(12);
314
- const cipher = createCipheriv("aes-256-gcm", key, iv);
315
- const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
316
- const tag = cipher.getAuthTag();
317
- const ciphertext = Buffer.concat([iv, encrypted, tag]).toString("base64");
318
-
319
- const signature = sign(null, Buffer.from(taskId + ciphertext), {
320
- key: PRIVATE_KEY,
321
- padding: constants.RSA_PKCS1_PSS_PADDING,
322
- saltLength: 32,
323
- }).toString("base64");
324
-
325
- const encryptedKeys: Record<string, string> = {};
326
- for (const [id, pub] of Object.entries(recipientPubKeys)) {
327
- encryptedKeys[id] = publicEncrypt(
328
- { key: pub, oaepHash: "sha256", padding: constants.RSA_PKCS1_OAEP_PADDING },
329
- key,
330
- ).toString("base64");
331
- }
332
- return { ciphertext, signature, encryptedKeys };
359
+ return _localEncrypt(plaintext, taskId, PRIVATE_KEY, recipientPubKeys);
333
360
  }
334
361
 
335
362
  function localDecrypt(
@@ -340,21 +367,7 @@ function localDecrypt(
340
367
  myEncKey: string,
341
368
  ): string {
342
369
  if (!PRIVATE_KEY) throw new Error("No private key configured");
343
- const valid = verify(null, Buffer.from(taskId + ciphertext), {
344
- key: senderPub,
345
- padding: constants.RSA_PKCS1_PSS_PADDING,
346
- saltLength: 32,
347
- }, Buffer.from(sig, "base64"));
348
- if (!valid) throw new Error("Signature verification failed");
349
-
350
- const aesKey = privateDecrypt(
351
- { key: PRIVATE_KEY, oaepHash: "sha256", padding: constants.RSA_PKCS1_OAEP_PADDING },
352
- Buffer.from(myEncKey, "base64"),
353
- );
354
- const data = Buffer.from(ciphertext, "base64");
355
- const decipher = createDecipheriv("aes-256-gcm", aesKey, data.subarray(0, 12));
356
- decipher.setAuthTag(data.subarray(-16));
357
- return Buffer.concat([decipher.update(data.subarray(12, -16)), decipher.final()]).toString("utf8");
370
+ return _localDecrypt(ciphertext, sig, taskId, senderPub, myEncKey, PRIVATE_KEY);
358
371
  }
359
372
 
360
373
  // ── MCP Server ───────────────────────────────────────────────────────────────
@@ -362,6 +375,7 @@ function localDecrypt(
362
375
  const instructions = [
363
376
  "You are connected to the pairai agent hub. Messages from other AI agents arrive as notifications.",
364
377
  "The channel server polls for updates automatically — you don't need to poll manually.",
378
+ "When the user asks about updates, new messages, or pending work, use pairai_check_updates (not pairai_list_tasks).",
365
379
  "",
366
380
  "Notification attributes:",
367
381
  " task_id — the task this message belongs to",
@@ -381,7 +395,7 @@ const capabilities = serveProvider === "claude"
381
395
  : { tools: {} };
382
396
 
383
397
  const mcp = new Server(
384
- { name: "pairai", version: "1.0.0" },
398
+ { name: "pairai", version: VERSION },
385
399
  { capabilities, instructions }
386
400
  );
387
401
 
@@ -389,6 +403,16 @@ const mcp = new Server(
389
403
 
390
404
  mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
391
405
  tools: [
406
+ {
407
+ name: "pairai_check_updates",
408
+ 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.",
409
+ inputSchema: {
410
+ type: "object" as const,
411
+ properties: {
412
+ acknowledge: { type: "boolean", description: "If true, marks all current updates as seen after returning them" },
413
+ },
414
+ },
415
+ },
392
416
  {
393
417
  name: "pairai_reply",
394
418
  description: "Send a message to the other agent in a task.",
@@ -463,6 +487,8 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
463
487
  description: { type: "string", description: "What this agent does (max 500 chars)" },
464
488
  capabilities: { type: "array", items: { type: "string" }, description: "List of capabilities, e.g. ['scheduling', 'code-review']" },
465
489
  metadata: { type: "object", description: "Arbitrary JSON metadata (max 4KB)" },
490
+ discoverable: { type: "boolean", description: "Whether to appear in the public agent directory" },
491
+ defaultApprovalRule: { type: "string", enum: ["auto", "require"], description: "Default approval rule for new connections" },
466
492
  },
467
493
  },
468
494
  },
@@ -478,9 +504,84 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
478
504
  required: ["connection_id"],
479
505
  },
480
506
  },
507
+ {
508
+ name: "pairai_update_webhook",
509
+ description: "Configure a webhook URL to receive events. Set url to null to disable.",
510
+ inputSchema: {
511
+ type: "object" as const,
512
+ properties: {
513
+ url: { type: ["string", "null"], description: "HTTPS webhook endpoint, or null to disable" },
514
+ secret: { type: "string", description: "Shared secret for HMAC-SHA256 signature (min 16 chars)" },
515
+ events: { type: "array", items: { type: "string" }, description: "Event types to receive (empty = all)" },
516
+ },
517
+ },
518
+ },
519
+ {
520
+ name: "pairai_discover_agents",
521
+ description: "Search the public directory of discoverable agents by capability or name.",
522
+ inputSchema: {
523
+ type: "object" as const,
524
+ properties: {
525
+ capability: { type: "string", description: "Filter by capability tag" },
526
+ query: { type: "string", description: "Search name and description" },
527
+ limit: { type: "number", description: "Max results (default 20)" },
528
+ },
529
+ },
530
+ },
531
+ {
532
+ name: "pairai_set_approval_rule",
533
+ description: "Set whether incoming tasks from a connection require human approval. 'auto' accepts automatically, 'require' holds tasks pending.",
534
+ inputSchema: {
535
+ type: "object" as const,
536
+ properties: {
537
+ connection_id: { type: "string", description: "Connection ID" },
538
+ rule: { type: "string", enum: ["auto", "require"], description: "Approval rule" },
539
+ },
540
+ required: ["connection_id", "rule"],
541
+ },
542
+ },
543
+ {
544
+ name: "pairai_disconnect",
545
+ description: "Disconnect from an agent. Cascades: cancels active tasks, notifies the other agent.",
546
+ inputSchema: {
547
+ type: "object" as const,
548
+ properties: {
549
+ connection_id: { type: "string", description: "Connection ID to delete" },
550
+ },
551
+ required: ["connection_id"],
552
+ },
553
+ },
554
+ {
555
+ name: "pairai_list_pending_approvals",
556
+ description: "List tasks waiting for your approval.",
557
+ inputSchema: { type: "object" as const, properties: {} },
558
+ },
559
+ {
560
+ name: "pairai_approve_task",
561
+ description: "Approve a task that is pending your approval.",
562
+ inputSchema: {
563
+ type: "object" as const,
564
+ properties: {
565
+ task_id: { type: "string", description: "Task ID" },
566
+ },
567
+ required: ["task_id"],
568
+ },
569
+ },
570
+ {
571
+ name: "pairai_reject_task",
572
+ description: "Reject a task pending your approval. Optionally provide a reason.",
573
+ inputSchema: {
574
+ type: "object" as const,
575
+ properties: {
576
+ task_id: { type: "string", description: "Task ID" },
577
+ reason: { type: "string", description: "Reason for rejection" },
578
+ },
579
+ required: ["task_id"],
580
+ },
581
+ },
481
582
  {
482
583
  name: "pairai_list_tasks",
483
- description: "List all tasks you are involved in (as initiator or target).",
584
+ 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.",
484
585
  inputSchema: {
485
586
  type: "object" as const,
486
587
  properties: {
@@ -513,6 +614,18 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
513
614
  required: ["task_id", "filename", "mime_type", "base64_content"],
514
615
  },
515
616
  },
617
+ {
618
+ name: "pairai_download_file",
619
+ description: "Download a file from a task. For encrypted tasks, the file is automatically decrypted.",
620
+ inputSchema: {
621
+ type: "object" as const,
622
+ properties: {
623
+ task_id: { type: "string", description: "Task ID the file belongs to" },
624
+ file_id: { type: "string", description: "File ID to download" },
625
+ },
626
+ required: ["task_id", "file_id"],
627
+ },
628
+ },
516
629
  {
517
630
  name: "pairai_create_encrypted_task",
518
631
  description: "Create an encrypted task. Title and description are encrypted — the hub cannot read them.",
@@ -531,7 +644,58 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
531
644
 
532
645
  mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
533
646
  const { name, arguments: a } = req.params;
534
- const args = a as Record<string, string>;
647
+ const args = a as Record<string, unknown>;
648
+
649
+ if (name === "pairai_check_updates") {
650
+ await loadPublicKeys();
651
+ const updates = (await hubGet("/updates")) as {
652
+ hasUpdates: boolean;
653
+ pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
654
+ unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
655
+ cursor: number;
656
+ };
657
+
658
+ if (!updates.hasUpdates) {
659
+ return { content: [{ type: "text" as const, text: "No updates. You're all caught up." }] };
660
+ }
661
+
662
+ const parts: string[] = [];
663
+
664
+ if (updates.pendingTasks.length > 0) {
665
+ const enriched: string[] = [];
666
+ for (const task of updates.pendingTasks) {
667
+ const full = (await hubGet(`/tasks/${task.id}`)) as any;
668
+ const desc = full.encrypted ? decryptTaskDescription(full, task.id) : (full.description ?? "");
669
+ const title = desc.split("\n")[0] || task.title;
670
+ enriched.push(`- "${title}" from ${task.fromAgent} (task ID: ${task.id})`);
671
+ }
672
+ parts.push(`**${updates.pendingTasks.length} pending task(s):**\n${enriched.join("\n")}`);
673
+ }
674
+
675
+ if (updates.unreadMessages.length > 0) {
676
+ const enriched: string[] = [];
677
+ for (const unread of updates.unreadMessages) {
678
+ const full = (await hubGet(`/tasks/${unread.taskId}`)) as any;
679
+ const msgs = (full.messages ?? []) as Array<any>;
680
+ const recent = msgs.slice(-unread.count);
681
+ const previews: string[] = [];
682
+ for (const m of recent) {
683
+ const d = full.encrypted ? decryptMessage(m, unread.taskId) : { content: m.content, contentType: m.contentType };
684
+ previews.push(d.content.slice(0, 100));
685
+ }
686
+ enriched.push(`- ${unread.count} new in "${unread.taskTitle}" (task ID: ${unread.taskId})\n Preview: ${previews.join(" | ")}`);
687
+ }
688
+ parts.push(`**Unread messages:**\n${enriched.join("\n")}`);
689
+ }
690
+
691
+ // Always ack — this is the authoritative "user has seen these" signal.
692
+ // The poll loop does NOT ack; only this tool does.
693
+ if (updates.cursor > 0) {
694
+ await hubPost("/updates/ack", { cursor: updates.cursor });
695
+ }
696
+
697
+ return { content: [{ type: "text" as const, text: parts.join("\n\n") }] };
698
+ }
535
699
 
536
700
  if (name === "pairai_reply") {
537
701
  const { task_id, text, content_type } = args as { task_id: string; text: string; content_type?: string };
@@ -548,17 +712,22 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
548
712
  return { content: [{ type: "text" as const, text: "Error: Cannot reply to encrypted task — missing cryptographic keys. Re-run setup or reconnect." }] };
549
713
  }
550
714
  const envelope = JSON.stringify({ contentType: content_type ?? "text", body: text });
551
- const { ciphertext, signature, encryptedKeys } = localEncrypt(envelope, task_id, {
552
- [myAgentId]: myPublicKey,
553
- [otherId]: otherPub,
554
- });
555
- await hubPost(`/tasks/${task_id}/messages`, {
556
- content: ciphertext,
557
- contentType: "encrypted",
558
- encryptedKeys,
559
- senderSignature: signature,
560
- });
561
- return { content: [{ type: "text" as const, text: "Sent (encrypted)." }] };
715
+ try {
716
+ const { ciphertext, signature, encryptedKeys } = localEncrypt(envelope, task_id, {
717
+ [myAgentId]: myPublicKey,
718
+ [otherId]: otherPub,
719
+ });
720
+ await hubPost(`/tasks/${task_id}/messages`, {
721
+ content: ciphertext,
722
+ contentType: "encrypted",
723
+ encryptedKeys,
724
+ senderSignature: signature,
725
+ });
726
+ return { content: [{ type: "text" as const, text: "Sent (encrypted)." }] };
727
+ } catch (err) {
728
+ console.error(`[pairai] encryption failed for task ${task_id}: ${(err as Error).message}`);
729
+ 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 };
730
+ }
562
731
  }
563
732
  // Non-encrypted task: send plaintext
564
733
  await hubPost(`/tasks/${task_id}/messages`, {
@@ -569,8 +738,16 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
569
738
  }
570
739
 
571
740
  if (name === "pairai_update_status") {
572
- await hubPatch(`/tasks/${args.task_id}`, { status: args.status });
573
- return { content: [{ type: "text" as const, text: `Status → ${args.status}` }] };
741
+ try {
742
+ await hubPatch(`/tasks/${args.task_id}`, { status: args.status });
743
+ return { content: [{ type: "text" as const, text: `Status → ${args.status}` }] };
744
+ } catch (err) {
745
+ const msg = (err as Error).message;
746
+ if (msg.includes("409") || msg.includes("400")) {
747
+ return { content: [{ type: "text" as const, text: `Cannot update status — ${msg}` }] };
748
+ }
749
+ throw err;
750
+ }
574
751
  }
575
752
 
576
753
  if (name === "pairai_get_profile") {
@@ -638,6 +815,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
638
815
  if (args.description !== undefined) body.description = args.description;
639
816
  if (args.capabilities !== undefined) body.capabilities = args.capabilities;
640
817
  if (args.metadata !== undefined) body.metadata = args.metadata;
818
+ if (args.discoverable !== undefined) body.discoverable = args.discoverable === "true" || args.discoverable === true;
819
+ if (args.defaultApprovalRule !== undefined) body.defaultApprovalRule = args.defaultApprovalRule;
641
820
  const data = await hubPatch("/agents/me", body);
642
821
  return { content: [{ type: "text" as const, text: "Profile updated.\n" + JSON.stringify(data, null, 2) }] };
643
822
  }
@@ -648,6 +827,57 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
648
827
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
649
828
  }
650
829
 
830
+ if (name === "pairai_update_webhook") {
831
+ const body: Record<string, unknown> = {};
832
+ if (args.url !== undefined) body.webhookUrl = args.url;
833
+ if (args.secret !== undefined) body.webhookSecret = args.secret;
834
+ if (args.events !== undefined) body.webhookEvents = args.events;
835
+ const data = await hubPatch("/agents/me", body);
836
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
837
+ }
838
+
839
+ if (name === "pairai_discover_agents") {
840
+ const params = new URLSearchParams();
841
+ if (args.capability) params.set("capability", args.capability);
842
+ if (args.query) params.set("q", args.query);
843
+ if (args.limit) params.set("limit", args.limit);
844
+ const qs = params.toString();
845
+ const data = await hubGet(`/agents/discover${qs ? `?${qs}` : ""}`);
846
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
847
+ }
848
+
849
+ if (name === "pairai_set_approval_rule") {
850
+ const { connection_id, rule } = args as { connection_id: string; rule: string };
851
+ const data = await hubPatch(`/connections/${connection_id}`, { approval: rule });
852
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
853
+ }
854
+
855
+ if (name === "pairai_disconnect") {
856
+ const { connection_id } = args as { connection_id: string };
857
+ try {
858
+ const result = (await hubDelete(`/connections/${connection_id}`)) as { cancelledTasks?: number };
859
+ return { content: [{ type: "text" as const, text: `Disconnected. ${result.cancelledTasks ?? 0} task(s) cancelled.` }] };
860
+ } catch (err) {
861
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
862
+ }
863
+ }
864
+
865
+ if (name === "pairai_list_pending_approvals") {
866
+ const data = await hubGet("/approvals");
867
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
868
+ }
869
+
870
+ if (name === "pairai_approve_task") {
871
+ const data = await hubPost(`/approvals/${args.task_id}/approve`);
872
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
873
+ }
874
+
875
+ if (name === "pairai_reject_task") {
876
+ const { task_id, reason } = args as { task_id: string; reason?: string };
877
+ const data = await hubPost(`/approvals/${task_id}/reject`, reason ? { reason } : undefined);
878
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
879
+ }
880
+
651
881
  if (name === "pairai_list_tasks") {
652
882
  await loadPublicKeys();
653
883
  const data = (await hubGet("/tasks")) as Array<{
@@ -677,13 +907,20 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
677
907
  encryptedKeys?: any; senderSignature?: string;
678
908
  }>;
679
909
  if (data.encrypted) {
680
- const desc = decryptTaskDescription(data, data.id);
681
- data.description = desc;
910
+ try {
911
+ data.description = decryptTaskDescription(data, data.id);
912
+ } catch {
913
+ data.description = "[decryption failed]";
914
+ }
682
915
  }
683
916
  const decryptedMsgs = msgs.map((m) => {
684
917
  if (data.encrypted) {
685
- const d = decryptMessage(m, data.id);
686
- return { ...m, content: d.content, contentType: d.contentType };
918
+ try {
919
+ const d = decryptMessage(m, data.id);
920
+ return { ...m, content: d.content, contentType: d.contentType };
921
+ } catch {
922
+ return { ...m, content: "[decryption failed]", contentType: "text" };
923
+ }
687
924
  }
688
925
  return m;
689
926
  });
@@ -694,12 +931,151 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
694
931
  const { task_id, filename, mime_type, base64_content } = args as {
695
932
  task_id: string; filename: string; mime_type: string; base64_content: string;
696
933
  };
934
+
935
+ // Check if task is encrypted
936
+ const taskData = (await hubGet(`/tasks/${task_id}`)) as any;
937
+ if (taskData.encrypted) {
938
+ // Size guardrail: encryption overhead (~37%) could exceed hub's 50MB limit
939
+ const rawSize = Buffer.from(base64_content, "base64").byteLength;
940
+ if (rawSize > 28 * 1024 * 1024) {
941
+ return { content: [{ type: "text" as const, text: "Error: File too large for encrypted upload (max ~28 MB before encryption overhead)." }] };
942
+ }
943
+
944
+ await loadPublicKeys();
945
+ const otherId =
946
+ taskData.initiatorAgentId === myAgentId ? taskData.targetAgentId : taskData.initiatorAgentId;
947
+ const otherPub = pubKeyCache.get(otherId);
948
+ if (!otherPub || !myPublicKey || !PRIVATE_KEY) {
949
+ return { content: [{ type: "text" as const, text: "Error: Cannot upload to encrypted task — missing cryptographic keys. Re-run setup or reconnect." }] };
950
+ }
951
+
952
+ const envelope = JSON.stringify({ filename, mimeType: mime_type, data: base64_content });
953
+ const { ciphertext, signature, encryptedKeys } = localEncrypt(envelope, task_id, {
954
+ [myAgentId]: myPublicKey,
955
+ [otherId]: otherPub,
956
+ });
957
+
958
+ const data = await hubPost(`/tasks/${task_id}/files/json`, {
959
+ filename: "encrypted_file",
960
+ mimeType: "application/octet-stream",
961
+ base64Content: ciphertext,
962
+ encryptedKeys,
963
+ senderSignature: signature,
964
+ });
965
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
966
+ }
967
+
968
+ // Non-encrypted task: pass through
697
969
  const data = await hubPost(`/tasks/${task_id}/files/json`, {
698
970
  filename, mimeType: mime_type, base64Content: base64_content,
699
971
  });
700
972
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
701
973
  }
702
974
 
975
+ if (name === "pairai_download_file") {
976
+ const { task_id, file_id } = args as { task_id: string; file_id: string };
977
+
978
+ // Fetch the task to check encryption status
979
+ const taskData = (await hubGet(`/tasks/${task_id}`)) as any;
980
+
981
+ // Download raw file bytes
982
+ const response = await fetch(`${HUB_URL}/files/${file_id}`, {
983
+ headers: { Authorization: `Bearer ${API_KEY}` },
984
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
985
+ });
986
+ if (!response.ok) {
987
+ return { content: [{ type: "text" as const, text: `Error: Failed to download file (${response.status}).` }] };
988
+ }
989
+ const fileBuffer = Buffer.from(await response.arrayBuffer());
990
+
991
+ if (!taskData.encrypted) {
992
+ // Non-encrypted: return raw file info
993
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
994
+ const disposition = response.headers.get("content-disposition") ?? "";
995
+ const filenameMatch = disposition.match(/filename="([^"]+)"/);
996
+ return {
997
+ content: [{
998
+ type: "text" as const,
999
+ text: JSON.stringify({
1000
+ filename: filenameMatch?.[1] ?? "file",
1001
+ mimeType: contentType,
1002
+ data: fileBuffer.toString("base64"),
1003
+ sizeBytes: fileBuffer.byteLength,
1004
+ }, null, 2),
1005
+ }],
1006
+ };
1007
+ }
1008
+
1009
+ // Encrypted task: find the message that holds the encryption keys for this file
1010
+ const taskMessages = (await hubGet(`/tasks/${task_id}/messages`)) as Array<{
1011
+ id: string; content: string; contentType: string; senderAgentId: string;
1012
+ encryptedKeys?: any; senderSignature?: string;
1013
+ }>;
1014
+ const fileMsg = taskMessages.find((m) => m.content === file_id);
1015
+ if (!fileMsg) {
1016
+ return { content: [{ type: "text" as const, text: "Error: Could not find message for this file." }] };
1017
+ }
1018
+
1019
+ // Legacy plaintext file in encrypted task (no keys stored)
1020
+ if (!fileMsg.encryptedKeys || !fileMsg.senderSignature) {
1021
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
1022
+ return {
1023
+ content: [{
1024
+ type: "text" as const,
1025
+ text: JSON.stringify({
1026
+ filename: "file",
1027
+ mimeType: contentType,
1028
+ data: fileBuffer.toString("base64"),
1029
+ sizeBytes: fileBuffer.byteLength,
1030
+ warning: "File was uploaded without encryption (legacy).",
1031
+ }, null, 2),
1032
+ }],
1033
+ };
1034
+ }
1035
+
1036
+ // Decrypt the file
1037
+ if (!PRIVATE_KEY) {
1038
+ return { content: [{ type: "text" as const, text: "Error: No private key configured. Re-run setup." }] };
1039
+ }
1040
+
1041
+ await loadPublicKeys();
1042
+ const senderPub = fileMsg.senderAgentId === myAgentId
1043
+ ? myPublicKey
1044
+ : pubKeyCache.get(fileMsg.senderAgentId);
1045
+ const keys = typeof fileMsg.encryptedKeys === "string"
1046
+ ? JSON.parse(fileMsg.encryptedKeys)
1047
+ : fileMsg.encryptedKeys;
1048
+ const myKey = keys[myAgentId];
1049
+
1050
+ if (!senderPub || !myKey) {
1051
+ return { content: [{ type: "text" as const, text: "Error: Decryption keys not found for this agent." }] };
1052
+ }
1053
+
1054
+ try {
1055
+ // The hub stores binary (decoded from base64 ciphertext); re-encode for localDecrypt
1056
+ const ciphertextB64 = fileBuffer.toString("base64");
1057
+ const plain = localDecrypt(ciphertextB64, fileMsg.senderSignature, task_id, senderPub, myKey);
1058
+ const envelope = JSON.parse(plain);
1059
+ return {
1060
+ content: [{
1061
+ type: "text" as const,
1062
+ text: JSON.stringify({
1063
+ filename: envelope.filename,
1064
+ mimeType: envelope.mimeType,
1065
+ data: envelope.data,
1066
+ sizeBytes: Buffer.from(envelope.data, "base64").byteLength,
1067
+ }, null, 2),
1068
+ }],
1069
+ };
1070
+ } catch (err) {
1071
+ const msg = (err as Error).message;
1072
+ if (msg.includes("Signature")) {
1073
+ return { content: [{ type: "text" as const, text: "Error: File signature verification failed — possible tampering." }] };
1074
+ }
1075
+ return { content: [{ type: "text" as const, text: `Error: Failed to decrypt file — ${msg}` }] };
1076
+ }
1077
+ }
1078
+
703
1079
  if (name === "pairai_create_encrypted_task") {
704
1080
  if (!PRIVATE_KEY)
705
1081
  return { content: [{ type: "text" as const, text: "No private key configured. Re-run setup." }] };
@@ -742,6 +1118,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
742
1118
  // ── Polling ──────────────────────────────────────────────────────────────────
743
1119
 
744
1120
  const seenMessages = new Set<string>();
1121
+ const SEEN_MESSAGES_MAX = 10_000;
745
1122
 
746
1123
  function decryptMessage(
747
1124
  msg: { content: string; contentType: string; senderAgentId: string; encryptedKeys?: any; senderSignature?: string },
@@ -798,13 +1175,16 @@ async function poll() {
798
1175
  hasUpdates: boolean;
799
1176
  pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
800
1177
  unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
1178
+ cursor: number;
801
1179
  };
802
1180
 
1181
+ debugLog(`poll: hasUpdates=${updates.hasUpdates} tasks=${updates.pendingTasks.length} messages=${updates.unreadMessages.length} cursor=${updates.cursor}`);
803
1182
  if (!updates.hasUpdates) return;
1183
+ console.error(`[pairai] poll: ${updates.pendingTasks.length} tasks, ${updates.unreadMessages.length} messages`);
804
1184
 
805
1185
  for (const task of updates.pendingTasks) {
806
1186
  const key = `task:${task.id}`;
807
- if (seenMessages.has(key)) continue;
1187
+ if (seenMessages.has(key)) { debugLog(`skip seen task ${task.id}`); continue; }
808
1188
  seenMessages.add(key);
809
1189
 
810
1190
  const full = (await hubGet(`/tasks/${task.id}`)) as {
@@ -813,78 +1193,152 @@ async function poll() {
813
1193
  descriptionKeys?: any;
814
1194
  senderSignature?: string;
815
1195
  initiatorAgentId?: string;
816
- messages: Array<{
817
- content: string;
818
- contentType: string;
819
- senderAgentId: string;
820
- encryptedKeys?: any;
821
- senderSignature?: string;
822
- }>;
1196
+ approvalStatus?: string | null;
823
1197
  };
1198
+ const taskMsgs = (await hubGet(`/tasks/${task.id}/messages`)) as Array<{
1199
+ content: string;
1200
+ contentType: string;
1201
+ senderAgentId: string;
1202
+ encryptedKeys?: any;
1203
+ senderSignature?: string;
1204
+ }>;
824
1205
 
825
1206
  const desc = decryptTaskDescription(full, task.id);
826
- const decryptedMessages = (full.messages ?? []).map((m) => {
827
- const d = decryptMessage(m, task.id);
828
- return d.content;
1207
+ const decryptedMessages = (taskMsgs ?? []).map((m) => {
1208
+ // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1209
+ if (m.contentType === "encrypted" && m.encryptedKeys && m.content.length < 30) {
1210
+ return "[File attachment — use pairai_download_file to retrieve]";
1211
+ }
1212
+ try {
1213
+ const d = decryptMessage(m, task.id);
1214
+ return d.content;
1215
+ } catch {
1216
+ return "[decryption failed]";
1217
+ }
829
1218
  });
830
1219
 
831
- const body = [desc || task.title, ...decryptedMessages.map((c) => `> ${c}`)].join("\n\n");
1220
+ const isPendingApproval = full.approvalStatus === "pending";
1221
+ const approvalPrefix = isPendingApproval ? "[APPROVAL REQUIRED] " : "";
1222
+ const approvalSuffix = isPendingApproval
1223
+ ? `\n\nThis task requires your approval before the agent will act on it.\nUse pairai_approve_task or pairai_reject_task with task ID: ${task.id}`
1224
+ : "";
832
1225
 
833
- await mcp.notification({
834
- method: "notifications/claude/channel",
835
- params: {
836
- content: body,
837
- meta: { task_id: task.id, task_title: task.title, from_agent: task.fromAgent, event_type: "new_task" },
838
- },
839
- });
1226
+ const body = approvalPrefix + [desc || task.title, ...decryptedMessages.map((c) => `> ${c}`)].join("\n\n") + approvalSuffix;
1227
+
1228
+ try {
1229
+ await mcp.notification({
1230
+ method: "notifications/claude/channel",
1231
+ params: {
1232
+ content: body,
1233
+ meta: { task_id: task.id, task_title: task.title, from_agent: task.fromAgent, event_type: "new_task" },
1234
+ },
1235
+ });
1236
+ console.error(`[pairai] channel notification sent: new_task ${task.id} from ${task.fromAgent}${isPendingApproval ? " [APPROVAL REQUIRED]" : ""}`);
1237
+ } catch (err) {
1238
+ console.error(`[pairai] channel notification FAILED: ${(err as Error).message}`);
1239
+ }
840
1240
  }
841
1241
 
842
1242
  for (const unread of updates.unreadMessages) {
843
- const full = (await hubGet(`/tasks/${unread.taskId}`)) as {
844
- messages: Array<{
845
- id: string;
846
- content: string;
847
- contentType: string;
848
- senderAgentId: string;
849
- encryptedKeys?: any;
850
- senderSignature?: string;
851
- }>;
852
- };
1243
+ const msgs = (await hubGet(`/tasks/${unread.taskId}/messages`)) as Array<{
1244
+ id: string;
1245
+ content: string;
1246
+ contentType: string;
1247
+ senderAgentId: string;
1248
+ encryptedKeys?: any;
1249
+ senderSignature?: string;
1250
+ }>;
853
1251
 
854
- if (!full?.messages) continue;
855
- for (const msg of full.messages.slice(-unread.count)) {
1252
+ debugLog(`unread: taskId=${unread.taskId} count=${unread.count} fetched=${msgs?.length ?? 0}`);
1253
+ if (!msgs || msgs.length === 0) continue;
1254
+ for (const msg of msgs.slice(-unread.count)) {
856
1255
  const key = `msg:${msg.id}`;
857
- if (seenMessages.has(key)) continue;
1256
+ if (seenMessages.has(key)) { debugLog(`skip seen msg ${msg.id}`); continue; }
858
1257
  seenMessages.add(key);
859
1258
 
860
- const decrypted = decryptMessage(msg, unread.taskId);
1259
+ // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1260
+ const isEncryptedFile = msg.contentType === "encrypted" && msg.encryptedKeys && msg.content.length < 30;
1261
+ let decrypted: { content: string; contentType: string };
1262
+ if (isEncryptedFile) {
1263
+ decrypted = { content: "[File attachment — use pairai_download_file to retrieve]", contentType: "text" };
1264
+ } else {
1265
+ try {
1266
+ decrypted = decryptMessage(msg, unread.taskId);
1267
+ } catch {
1268
+ decrypted = { content: "[decryption failed]", contentType: "text" };
1269
+ }
1270
+ }
861
1271
 
862
- await mcp.notification({
863
- method: "notifications/claude/channel",
864
- params: {
865
- content: decrypted.content,
866
- meta: {
867
- task_id: unread.taskId,
868
- task_title: unread.taskTitle,
869
- from_agent: msg.senderAgentId,
870
- event_type: "new_message",
871
- content_type: decrypted.contentType,
1272
+ try {
1273
+ await mcp.notification({
1274
+ method: "notifications/claude/channel",
1275
+ params: {
1276
+ content: decrypted.content,
1277
+ meta: {
1278
+ task_id: unread.taskId,
1279
+ task_title: unread.taskTitle,
1280
+ from_agent: msg.senderAgentId,
1281
+ event_type: "new_message",
1282
+ content_type: decrypted.contentType,
1283
+ },
872
1284
  },
873
- },
874
- });
1285
+ });
1286
+ console.error(`[pairai] channel notification sent: new_message in ${unread.taskId}`);
1287
+ } catch (err) {
1288
+ console.error(`[pairai] channel notification FAILED: ${(err as Error).message}`);
1289
+ }
875
1290
  }
876
1291
  }
877
1292
 
878
- await hubPost("/updates/ack");
1293
+ // Do NOT ack the hub here — the hub's lastSeenRowid is only advanced
1294
+ // when the user explicitly calls pairai_check_updates (authoritative ack).
1295
+ // The seenMessages Set prevents duplicate notifications within this session.
1296
+ debugLog(`poll: processed cursor=${updates.cursor} (hub NOT acked — seenMessages dedup only)`);
1297
+
1298
+ // Prevent unbounded memory growth
1299
+ if (seenMessages.size > SEEN_MESSAGES_MAX) {
1300
+ const excess = seenMessages.size - SEEN_MESSAGES_MAX;
1301
+ const iter = seenMessages.values();
1302
+ for (let i = 0; i < excess; i++) seenMessages.delete(iter.next().value!);
1303
+ }
879
1304
  } catch (err) {
880
1305
  console.error(`[pairai] poll error: ${(err as Error).message}`);
1306
+ debugLog(`poll error: ${(err as Error).message}`);
881
1307
  }
882
1308
  }
883
1309
 
884
1310
  // ── Start ────────────────────────────────────────────────────────────────────
885
1311
 
886
1312
  await mcp.connect(new StdioServerTransport());
1313
+ console.error(`[pairai] connected. provider=${serveProvider} channel=${!!capabilities.experimental?.["claude/channel"]} agent=${myAgentId || "(loading)"}`);
887
1314
  await loadAgentInfo();
1315
+ if (!myAgentId) {
1316
+ console.error("[pairai] failed to load agent info from hub. Cannot start polling.");
1317
+ process.exit(1);
1318
+ }
888
1319
  await loadPublicKeys();
1320
+
1321
+ // Acquire polling lock — only one channel process per agent
1322
+ const lockDir = process.env.PAIRAI_LOCK_DIR || undefined;
1323
+ if (!acquireLock(myAgentId, lockDir)) {
1324
+ console.error(`[pairai] another instance is already polling for agent ${myAgentId}. Exiting.`);
1325
+ process.exit(0);
1326
+ }
1327
+ const cleanupLock = () => { try { releaseLock(myAgentId, lockDir); } catch {} };
1328
+ process.on("SIGTERM", cleanupLock);
1329
+ process.on("SIGINT", cleanupLock);
1330
+ process.on("beforeExit", cleanupLock);
1331
+ process.on("exit", cleanupLock);
1332
+
1333
+ // Detect parent death — when the MCP host (Claude/Gemini) exits, stdin closes.
1334
+ // Without this, the process becomes an orphan reparented to systemd.
1335
+ process.stdin.on("end", () => {
1336
+ console.error("[pairai] stdin closed (parent exited). Shutting down.");
1337
+ cleanupLock();
1338
+ process.exit(0);
1339
+ });
1340
+ process.stdin.resume(); // ensure 'end' fires even if nothing reads stdin
1341
+
1342
+ console.error(`[pairai] agent=${myAgentId} keys=${pubKeyCache.size} polling every ${POLL_MS}ms`);
889
1343
  setInterval(poll, POLL_MS);
890
1344
  poll();