pairai 0.3.0 → 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.
- package/lib.ts +70 -1
- package/package.json +3 -2
- package/pairai.ts +372 -99
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
|
|
|
@@ -247,3 +252,67 @@ export function releaseLock(agentId: string, lockDir?: string): void {
|
|
|
247
252
|
const path = lockPath(agentId, lockDir);
|
|
248
253
|
try { unlinkSync(path); } catch {}
|
|
249
254
|
}
|
|
255
|
+
|
|
256
|
+
// ── Crypto ──────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Encrypt plaintext with AES-256-GCM, wrap key with RSA-OAEP for each recipient,
|
|
260
|
+
* sign (taskId + ciphertext) with RSA-PSS.
|
|
261
|
+
*/
|
|
262
|
+
export function localEncrypt(
|
|
263
|
+
plaintext: string,
|
|
264
|
+
taskId: string,
|
|
265
|
+
senderPrivateKey: string,
|
|
266
|
+
recipientPubKeys: Record<string, string>,
|
|
267
|
+
): { ciphertext: string; signature: string; encryptedKeys: Record<string, string> } {
|
|
268
|
+
const key = randomBytes(32);
|
|
269
|
+
const iv = randomBytes(12);
|
|
270
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
271
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
272
|
+
const tag = cipher.getAuthTag();
|
|
273
|
+
const ciphertext = Buffer.concat([iv, encrypted, tag]).toString("base64");
|
|
274
|
+
|
|
275
|
+
const signature = sign(null, Buffer.from(taskId + ciphertext), {
|
|
276
|
+
key: senderPrivateKey,
|
|
277
|
+
padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
|
|
278
|
+
saltLength: 32,
|
|
279
|
+
}).toString("base64");
|
|
280
|
+
|
|
281
|
+
const encryptedKeys: Record<string, string> = {};
|
|
282
|
+
for (const [id, pub] of Object.entries(recipientPubKeys)) {
|
|
283
|
+
encryptedKeys[id] = publicEncrypt(
|
|
284
|
+
{ key: pub, oaepHash: "sha256", padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING },
|
|
285
|
+
key,
|
|
286
|
+
).toString("base64");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { ciphertext, signature, encryptedKeys };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Verify signature, unwrap AES key with own private key, decrypt AES-256-GCM.
|
|
294
|
+
*/
|
|
295
|
+
export function localDecrypt(
|
|
296
|
+
ciphertext: string,
|
|
297
|
+
sig: string,
|
|
298
|
+
taskId: string,
|
|
299
|
+
senderPub: string,
|
|
300
|
+
myEncKey: string,
|
|
301
|
+
myPrivateKey: string,
|
|
302
|
+
): string {
|
|
303
|
+
const valid = verify(null, Buffer.from(taskId + ciphertext), {
|
|
304
|
+
key: senderPub,
|
|
305
|
+
padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
|
|
306
|
+
saltLength: 32,
|
|
307
|
+
}, Buffer.from(sig, "base64"));
|
|
308
|
+
if (!valid) throw new Error("Signature verification failed");
|
|
309
|
+
|
|
310
|
+
const aesKey = privateDecrypt(
|
|
311
|
+
{ key: myPrivateKey, oaepHash: "sha256", padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING },
|
|
312
|
+
Buffer.from(myEncKey, "base64"),
|
|
313
|
+
);
|
|
314
|
+
const data = Buffer.from(ciphertext, "base64");
|
|
315
|
+
const decipher = createDecipheriv("aes-256-gcm", aesKey, data.subarray(0, 12));
|
|
316
|
+
decipher.setAuthTag(data.subarray(-16));
|
|
317
|
+
return Buffer.concat([decipher.update(data.subarray(12, -16)), decipher.final()]).toString("utf8");
|
|
318
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pairai",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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://
|
|
10
|
+
"url": "https://github.com/pairaipro/pairai",
|
|
11
|
+
"directory": "channel"
|
|
11
12
|
},
|
|
12
13
|
"keywords": [
|
|
13
14
|
"ai",
|
package/pairai.ts
CHANGED
|
@@ -8,20 +8,21 @@
|
|
|
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
|
|
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
|
-
|
|
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";
|
|
26
27
|
|
|
27
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -31,6 +32,19 @@ const VERSION: string = PKG.version;
|
|
|
31
32
|
const args = process.argv.slice(2);
|
|
32
33
|
const command = args[0];
|
|
33
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
|
+
|
|
34
48
|
// ── Version ─────────────────────────────────────────────────────────────────
|
|
35
49
|
|
|
36
50
|
if (command === "version" || args.includes("--version") || args.includes("-v")) {
|
|
@@ -72,6 +86,20 @@ if (command === "setup") {
|
|
|
72
86
|
const rest = args.slice(1);
|
|
73
87
|
const hubIdx = rest.indexOf("--hub");
|
|
74
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
|
+
}
|
|
75
103
|
const providerIdx = rest.indexOf("--provider");
|
|
76
104
|
const providerArg = providerIdx !== -1 ? rest.splice(providerIdx, 2)[1] : undefined;
|
|
77
105
|
if (providerArg) {
|
|
@@ -129,7 +157,7 @@ if (command === "setup") {
|
|
|
129
157
|
console.log(` API Key: ${apiKey}`);
|
|
130
158
|
|
|
131
159
|
const keyDir = join(homedir(), ".pairai", "keys");
|
|
132
|
-
mkdirSync(keyDir, { recursive: true });
|
|
160
|
+
mkdirSync(keyDir, { recursive: true, mode: 0o700 });
|
|
133
161
|
const keyPath = join(keyDir, `${id}.pem`);
|
|
134
162
|
writeFileSync(keyPath, privateKey, { mode: 0o600 });
|
|
135
163
|
console.log(` Private key: ${keyPath}`);
|
|
@@ -149,7 +177,7 @@ if (command === "setup") {
|
|
|
149
177
|
};
|
|
150
178
|
|
|
151
179
|
// Ensure config directory exists
|
|
152
|
-
mkdirSync(dirname(cfg.configPath), { recursive: true });
|
|
180
|
+
mkdirSync(dirname(cfg.configPath), { recursive: true, mode: 0o700 });
|
|
153
181
|
|
|
154
182
|
if (cfg.format === "toml") {
|
|
155
183
|
// Codex CLI uses TOML
|
|
@@ -165,14 +193,14 @@ if (command === "setup") {
|
|
|
165
193
|
`PAIRAI_AGENT_CRED = "${apiKey}"`,
|
|
166
194
|
`PAIRAI_KEY_FILE = "${keyPath}"`,
|
|
167
195
|
].join("\n");
|
|
168
|
-
writeFileSync(cfg.configPath, existing + tomlBlock + "\n");
|
|
196
|
+
writeFileSync(cfg.configPath, existing + tomlBlock + "\n", { mode: 0o600 });
|
|
169
197
|
} else {
|
|
170
198
|
// JSON — merge with existing config
|
|
171
199
|
let existing: any = {};
|
|
172
200
|
try { if (existsSync(cfg.configPath)) existing = JSON.parse(readFileSync(cfg.configPath, "utf-8")); } catch {}
|
|
173
201
|
if (!existing.mcpServers) existing.mcpServers = {};
|
|
174
202
|
existing.mcpServers[cfg.mcpKey] = serverEntry;
|
|
175
|
-
writeFileSync(cfg.configPath, JSON.stringify(existing, null, 2) + "\n");
|
|
203
|
+
writeFileSync(cfg.configPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
|
|
176
204
|
}
|
|
177
205
|
|
|
178
206
|
console.log(` Config: ${cfg.configPath}`);
|
|
@@ -200,6 +228,15 @@ if (command !== "serve") {
|
|
|
200
228
|
console.error(" npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]");
|
|
201
229
|
console.error(" npx pairai upgrade — update to latest version");
|
|
202
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");
|
|
203
240
|
process.exit(1);
|
|
204
241
|
}
|
|
205
242
|
|
|
@@ -219,6 +256,16 @@ if (serveProviderArg) {
|
|
|
219
256
|
const serveProvider = (serveProviderArg as Provider) ?? "claude";
|
|
220
257
|
|
|
221
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
|
+
}
|
|
222
269
|
const API_KEY = process.env.PAIRAI_AGENT_CRED ?? process.env.PAIRAI_API_KEY;
|
|
223
270
|
const POLL_MS = Number(process.env.PAIRAI_POLL_MS ?? "5000");
|
|
224
271
|
const PRIVATE_KEY_PATH = process.env.PAIRAI_KEY_FILE ?? process.env.PAIRAI_PRIVATE_KEY_PATH;
|
|
@@ -236,29 +283,51 @@ const headers = {
|
|
|
236
283
|
|
|
237
284
|
// ── Hub API ──────────────────────────────────────────────────────────────────
|
|
238
285
|
|
|
286
|
+
const HUB_TIMEOUT_MS = 30_000;
|
|
287
|
+
|
|
288
|
+
const API_PREFIX = "/api/v1";
|
|
289
|
+
|
|
239
290
|
async function hubGet(path: string) {
|
|
240
|
-
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) });
|
|
241
292
|
if (!res.ok) throw new Error(`GET ${path}: ${res.status}`);
|
|
242
293
|
return res.json();
|
|
243
294
|
}
|
|
244
295
|
|
|
245
296
|
async function hubPost(path: string, body?: unknown) {
|
|
246
|
-
const res = await fetch(`${HUB_URL}${path}`, {
|
|
297
|
+
const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
|
|
247
298
|
method: "POST",
|
|
248
299
|
headers: body ? headers : { Authorization: headers.Authorization },
|
|
249
300
|
body: body ? JSON.stringify(body) : undefined,
|
|
301
|
+
signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
|
|
250
302
|
});
|
|
251
303
|
if (!res.ok) throw new Error(`POST ${path}: ${res.status}`);
|
|
252
304
|
return res.json();
|
|
253
305
|
}
|
|
254
306
|
|
|
255
307
|
async function hubPatch(path: string, body: unknown) {
|
|
256
|
-
const res = await fetch(`${HUB_URL}${path}`, {
|
|
308
|
+
const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, {
|
|
257
309
|
method: "PATCH",
|
|
258
310
|
headers,
|
|
259
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),
|
|
260
326
|
});
|
|
261
|
-
if (!res.ok)
|
|
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
|
+
}
|
|
262
331
|
return res.json();
|
|
263
332
|
}
|
|
264
333
|
|
|
@@ -287,27 +356,7 @@ async function loadPublicKeys() {
|
|
|
287
356
|
|
|
288
357
|
function localEncrypt(plaintext: string, taskId: string, recipientPubKeys: Record<string, string>) {
|
|
289
358
|
if (!PRIVATE_KEY) throw new Error("No private key configured");
|
|
290
|
-
|
|
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 };
|
|
359
|
+
return _localEncrypt(plaintext, taskId, PRIVATE_KEY, recipientPubKeys);
|
|
311
360
|
}
|
|
312
361
|
|
|
313
362
|
function localDecrypt(
|
|
@@ -318,21 +367,7 @@ function localDecrypt(
|
|
|
318
367
|
myEncKey: string,
|
|
319
368
|
): string {
|
|
320
369
|
if (!PRIVATE_KEY) throw new Error("No private key configured");
|
|
321
|
-
|
|
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");
|
|
370
|
+
return _localDecrypt(ciphertext, sig, taskId, senderPub, myEncKey, PRIVATE_KEY);
|
|
336
371
|
}
|
|
337
372
|
|
|
338
373
|
// ── MCP Server ───────────────────────────────────────────────────────────────
|
|
@@ -340,6 +375,7 @@ function localDecrypt(
|
|
|
340
375
|
const instructions = [
|
|
341
376
|
"You are connected to the pairai agent hub. Messages from other AI agents arrive as notifications.",
|
|
342
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).",
|
|
343
379
|
"",
|
|
344
380
|
"Notification attributes:",
|
|
345
381
|
" task_id — the task this message belongs to",
|
|
@@ -369,7 +405,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
369
405
|
tools: [
|
|
370
406
|
{
|
|
371
407
|
name: "pairai_check_updates",
|
|
372
|
-
description: "Check for
|
|
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.",
|
|
373
409
|
inputSchema: {
|
|
374
410
|
type: "object" as const,
|
|
375
411
|
properties: {
|
|
@@ -504,6 +540,17 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
504
540
|
required: ["connection_id", "rule"],
|
|
505
541
|
},
|
|
506
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
|
+
},
|
|
507
554
|
{
|
|
508
555
|
name: "pairai_list_pending_approvals",
|
|
509
556
|
description: "List tasks waiting for your approval.",
|
|
@@ -534,7 +581,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
534
581
|
},
|
|
535
582
|
{
|
|
536
583
|
name: "pairai_list_tasks",
|
|
537
|
-
description: "List
|
|
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.",
|
|
538
585
|
inputSchema: {
|
|
539
586
|
type: "object" as const,
|
|
540
587
|
properties: {
|
|
@@ -567,6 +614,18 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
567
614
|
required: ["task_id", "filename", "mime_type", "base64_content"],
|
|
568
615
|
},
|
|
569
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
|
+
},
|
|
570
629
|
{
|
|
571
630
|
name: "pairai_create_encrypted_task",
|
|
572
631
|
description: "Create an encrypted task. Title and description are encrypted — the hub cannot read them.",
|
|
@@ -593,6 +652,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
593
652
|
hasUpdates: boolean;
|
|
594
653
|
pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
|
|
595
654
|
unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
|
|
655
|
+
cursor: number;
|
|
596
656
|
};
|
|
597
657
|
|
|
598
658
|
if (!updates.hasUpdates) {
|
|
@@ -628,8 +688,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
628
688
|
parts.push(`**Unread messages:**\n${enriched.join("\n")}`);
|
|
629
689
|
}
|
|
630
690
|
|
|
631
|
-
|
|
632
|
-
|
|
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 });
|
|
633
695
|
}
|
|
634
696
|
|
|
635
697
|
return { content: [{ type: "text" as const, text: parts.join("\n\n") }] };
|
|
@@ -650,17 +712,22 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
650
712
|
return { content: [{ type: "text" as const, text: "Error: Cannot reply to encrypted task — missing cryptographic keys. Re-run setup or reconnect." }] };
|
|
651
713
|
}
|
|
652
714
|
const envelope = JSON.stringify({ contentType: content_type ?? "text", body: text });
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
+
}
|
|
664
731
|
}
|
|
665
732
|
// Non-encrypted task: send plaintext
|
|
666
733
|
await hubPost(`/tasks/${task_id}/messages`, {
|
|
@@ -671,8 +738,16 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
671
738
|
}
|
|
672
739
|
|
|
673
740
|
if (name === "pairai_update_status") {
|
|
674
|
-
|
|
675
|
-
|
|
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
|
+
}
|
|
676
751
|
}
|
|
677
752
|
|
|
678
753
|
if (name === "pairai_get_profile") {
|
|
@@ -777,6 +852,16 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
777
852
|
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
778
853
|
}
|
|
779
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
|
+
|
|
780
865
|
if (name === "pairai_list_pending_approvals") {
|
|
781
866
|
const data = await hubGet("/approvals");
|
|
782
867
|
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
@@ -822,13 +907,20 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
822
907
|
encryptedKeys?: any; senderSignature?: string;
|
|
823
908
|
}>;
|
|
824
909
|
if (data.encrypted) {
|
|
825
|
-
|
|
826
|
-
|
|
910
|
+
try {
|
|
911
|
+
data.description = decryptTaskDescription(data, data.id);
|
|
912
|
+
} catch {
|
|
913
|
+
data.description = "[decryption failed]";
|
|
914
|
+
}
|
|
827
915
|
}
|
|
828
916
|
const decryptedMsgs = msgs.map((m) => {
|
|
829
917
|
if (data.encrypted) {
|
|
830
|
-
|
|
831
|
-
|
|
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
|
+
}
|
|
832
924
|
}
|
|
833
925
|
return m;
|
|
834
926
|
});
|
|
@@ -839,12 +931,151 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
839
931
|
const { task_id, filename, mime_type, base64_content } = args as {
|
|
840
932
|
task_id: string; filename: string; mime_type: string; base64_content: string;
|
|
841
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
|
|
842
969
|
const data = await hubPost(`/tasks/${task_id}/files/json`, {
|
|
843
970
|
filename, mimeType: mime_type, base64Content: base64_content,
|
|
844
971
|
});
|
|
845
972
|
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
846
973
|
}
|
|
847
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
|
+
|
|
848
1079
|
if (name === "pairai_create_encrypted_task") {
|
|
849
1080
|
if (!PRIVATE_KEY)
|
|
850
1081
|
return { content: [{ type: "text" as const, text: "No private key configured. Re-run setup." }] };
|
|
@@ -887,6 +1118,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
887
1118
|
// ── Polling ──────────────────────────────────────────────────────────────────
|
|
888
1119
|
|
|
889
1120
|
const seenMessages = new Set<string>();
|
|
1121
|
+
const SEEN_MESSAGES_MAX = 10_000;
|
|
890
1122
|
|
|
891
1123
|
function decryptMessage(
|
|
892
1124
|
msg: { content: string; contentType: string; senderAgentId: string; encryptedKeys?: any; senderSignature?: string },
|
|
@@ -943,13 +1175,16 @@ async function poll() {
|
|
|
943
1175
|
hasUpdates: boolean;
|
|
944
1176
|
pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
|
|
945
1177
|
unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
|
|
1178
|
+
cursor: number;
|
|
946
1179
|
};
|
|
947
1180
|
|
|
1181
|
+
debugLog(`poll: hasUpdates=${updates.hasUpdates} tasks=${updates.pendingTasks.length} messages=${updates.unreadMessages.length} cursor=${updates.cursor}`);
|
|
948
1182
|
if (!updates.hasUpdates) return;
|
|
1183
|
+
console.error(`[pairai] poll: ${updates.pendingTasks.length} tasks, ${updates.unreadMessages.length} messages`);
|
|
949
1184
|
|
|
950
1185
|
for (const task of updates.pendingTasks) {
|
|
951
1186
|
const key = `task:${task.id}`;
|
|
952
|
-
if (seenMessages.has(key)) continue;
|
|
1187
|
+
if (seenMessages.has(key)) { debugLog(`skip seen task ${task.id}`); continue; }
|
|
953
1188
|
seenMessages.add(key);
|
|
954
1189
|
|
|
955
1190
|
const full = (await hubGet(`/tasks/${task.id}`)) as {
|
|
@@ -959,19 +1194,27 @@ async function poll() {
|
|
|
959
1194
|
senderSignature?: string;
|
|
960
1195
|
initiatorAgentId?: string;
|
|
961
1196
|
approvalStatus?: string | null;
|
|
962
|
-
messages: Array<{
|
|
963
|
-
content: string;
|
|
964
|
-
contentType: string;
|
|
965
|
-
senderAgentId: string;
|
|
966
|
-
encryptedKeys?: any;
|
|
967
|
-
senderSignature?: string;
|
|
968
|
-
}>;
|
|
969
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
|
+
}>;
|
|
970
1205
|
|
|
971
1206
|
const desc = decryptTaskDescription(full, task.id);
|
|
972
|
-
const decryptedMessages = (
|
|
973
|
-
|
|
974
|
-
|
|
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
|
+
}
|
|
975
1218
|
});
|
|
976
1219
|
|
|
977
1220
|
const isPendingApproval = full.approvalStatus === "pending";
|
|
@@ -997,24 +1240,34 @@ async function poll() {
|
|
|
997
1240
|
}
|
|
998
1241
|
|
|
999
1242
|
for (const unread of updates.unreadMessages) {
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
}>;
|
|
1009
|
-
};
|
|
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
|
+
}>;
|
|
1010
1251
|
|
|
1011
|
-
|
|
1012
|
-
|
|
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)) {
|
|
1013
1255
|
const key = `msg:${msg.id}`;
|
|
1014
|
-
if (seenMessages.has(key)) continue;
|
|
1256
|
+
if (seenMessages.has(key)) { debugLog(`skip seen msg ${msg.id}`); continue; }
|
|
1015
1257
|
seenMessages.add(key);
|
|
1016
1258
|
|
|
1017
|
-
|
|
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
|
+
}
|
|
1018
1271
|
|
|
1019
1272
|
try {
|
|
1020
1273
|
await mcp.notification({
|
|
@@ -1037,9 +1290,20 @@ async function poll() {
|
|
|
1037
1290
|
}
|
|
1038
1291
|
}
|
|
1039
1292
|
|
|
1040
|
-
|
|
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
|
+
}
|
|
1041
1304
|
} catch (err) {
|
|
1042
1305
|
console.error(`[pairai] poll error: ${(err as Error).message}`);
|
|
1306
|
+
debugLog(`poll error: ${(err as Error).message}`);
|
|
1043
1307
|
}
|
|
1044
1308
|
}
|
|
1045
1309
|
|
|
@@ -1066,6 +1330,15 @@ process.on("SIGINT", cleanupLock);
|
|
|
1066
1330
|
process.on("beforeExit", cleanupLock);
|
|
1067
1331
|
process.on("exit", cleanupLock);
|
|
1068
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
|
+
|
|
1069
1342
|
console.error(`[pairai] agent=${myAgentId} keys=${pubKeyCache.size} polling every ${POLL_MS}ms`);
|
|
1070
1343
|
setInterval(poll, POLL_MS);
|
|
1071
1344
|
poll();
|