pairai 0.3.2 → 0.4.3

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 +10 -8
  2. package/package.json +3 -1
  3. package/pairai.ts +477 -18
package/lib.ts CHANGED
@@ -28,16 +28,18 @@ export function validateProvider(value: string): Provider {
28
28
 
29
29
  /**
30
30
  * Auto-detect provider based on environment and filesystem.
31
+ * Returns null when detection is ambiguous (0 or 2+ matches).
31
32
  */
32
- export function detectProvider(): Provider {
33
+ export function detectProvider(): Provider | null {
33
34
  if (process.env.GEMINI_CLI) return "gemini";
34
- try { if (statSync(".cursor").isDirectory()) return "cursor"; } catch {}
35
- try { if (statSync(".windsurf").isDirectory()) return "windsurf"; } catch {}
36
- try { if (statSync(".vscode").isDirectory()) return "copilot"; } catch {}
37
- try { if (statSync(".codex").isDirectory()) return "codex"; } catch {}
38
- try { if (statSync(".amazonq").isDirectory()) return "amazonq"; } catch {}
39
- try { if (statSync(".gemini").isDirectory()) return "gemini"; } catch {}
40
- 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;
41
43
  }
42
44
 
43
45
  export interface ProviderConfig {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pairai",
3
- "version": "0.3.2",
3
+ "version": "0.4.3",
4
4
  "description": "pairai CLI — connect AI agents to collaborate via the pairai hub",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,8 @@
29
29
  "README.md"
30
30
  ],
31
31
  "dependencies": {
32
+ "@inquirer/input": "^5.0.10",
33
+ "@inquirer/select": "^5.1.2",
32
34
  "@modelcontextprotocol/sdk": "^1.10.0",
33
35
  "nanoid": "^5.0.0",
34
36
  "tsx": "^4.0.0"
package/pairai.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * Commands:
6
6
  * npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]
7
7
  * npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]
8
+ * npx pairai uninstall [--provider ...] [--delete-agent] — remove MCP config, save credentials to ~/.pairai/agents/
8
9
  * npx pairai upgrade — update to latest version (preserves keys and config)
9
10
  * npx pairai version — show current version
10
11
  *
@@ -24,11 +25,23 @@ import { join, dirname } from "node:path";
24
25
  import { fileURLToPath } from "node:url";
25
26
  import { validateProvider, detectProvider, checkExistingConfig, formatKeyBackupBox, acquireLock, releaseLock, getProviderConfig, localEncrypt as _localEncrypt, localDecrypt as _localDecrypt } from "./lib.js";
26
27
  import type { Provider } from "./lib.js";
28
+ import select from "@inquirer/select";
29
+ import input from "@inquirer/input";
27
30
 
28
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
29
32
  const PKG = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
30
33
  const VERSION: string = PKG.version;
31
34
 
35
+ const PROVIDER_CHOICES: { name: string; value: Provider }[] = [
36
+ { name: "Claude Code", value: "claude" },
37
+ { name: "Gemini CLI", value: "gemini" },
38
+ { name: "Cursor", value: "cursor" },
39
+ { name: "GitHub Copilot (VS Code)", value: "copilot" },
40
+ { name: "Windsurf", value: "windsurf" },
41
+ { name: "OpenAI Codex CLI", value: "codex" },
42
+ { name: "Amazon Q", value: "amazonq" },
43
+ ];
44
+
32
45
  const args = process.argv.slice(2);
33
46
  const command = args[0];
34
47
 
@@ -52,6 +65,26 @@ if (command === "version" || args.includes("--version") || args.includes("-v"))
52
65
  process.exit(0);
53
66
  }
54
67
 
68
+ // ── Help ────────────────────────────────────────────────────────────────────
69
+
70
+ if (command === "help" || args.includes("--help") || args.includes("-h")) {
71
+ console.log(`pairai v${VERSION}\n`);
72
+ console.log("Commands:");
73
+ console.log(' setup "Agent Name" [--hub URL] [--provider ...] [--global] [--force]');
74
+ console.log(" serve [--provider ...] — start the MCP channel server");
75
+ console.log(" uninstall [--provider ...] [--delete-agent]");
76
+ console.log(" upgrade — update to latest version");
77
+ console.log(" version — show version");
78
+ console.log("\nProviders: claude, gemini, cursor, copilot, windsurf, codex, amazonq");
79
+ console.log("\nEnvironment variables:");
80
+ console.log(" PAIRAI_HUB_URL Hub URL (default: https://pairai.pro)");
81
+ console.log(" PAIRAI_AGENT_CRED Agent API key");
82
+ console.log(" PAIRAI_KEY_FILE Path to RSA private key .pem");
83
+ console.log(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
84
+ console.log(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
85
+ process.exit(0);
86
+ }
87
+
55
88
  // ── Upgrade ─────────────────────────────────────────────────────────────────
56
89
 
57
90
  if (command === "upgrade") {
@@ -80,6 +113,202 @@ if (command === "upgrade") {
80
113
  // detectProvider, validateProvider, checkExistingConfig,
81
114
  // formatKeyBackupBox are imported from ./lib.js
82
115
 
116
+ // ── Uninstall: remove MCP config, preserve keys and credentials ─────────────
117
+
118
+ if (command === "uninstall") {
119
+ const rest = args.slice(1);
120
+ const providerIdx = rest.indexOf("--provider");
121
+ const providerArg = providerIdx !== -1 ? rest.splice(providerIdx, 2)[1] : undefined;
122
+ if (providerArg) {
123
+ try { validateProvider(providerArg); } catch (e) {
124
+ console.error(` ${(e as Error).message}`);
125
+ process.exit(1);
126
+ }
127
+ }
128
+ const deleteAgent = rest.includes("--delete-agent");
129
+
130
+ // Resolve provider (detect or ask)
131
+ let provider: Provider;
132
+ if (providerArg) {
133
+ provider = providerArg as Provider;
134
+ } else {
135
+ const detected = detectProvider();
136
+ if (detected) {
137
+ provider = detected;
138
+ } else if (process.stdin.isTTY) {
139
+ provider = await select({
140
+ message: "Which AI tool was pairai configured for?",
141
+ choices: PROVIDER_CHOICES,
142
+ });
143
+ } else {
144
+ console.error('Cannot auto-detect provider. Use --provider flag (e.g. npx pairai uninstall --provider claude)');
145
+ process.exit(1);
146
+ }
147
+ }
148
+
149
+ console.log(`\n pairai uninstall (provider: ${provider})\n`);
150
+
151
+ const cwd = process.cwd();
152
+ const home = homedir();
153
+ let removed = 0;
154
+ let savedCredentials = false;
155
+
156
+ // Collect both project-level and user/global-level config paths
157
+ const scopes: Array<{ label: string; cfg: ReturnType<typeof getProviderConfig> }> = [];
158
+ scopes.push({ label: "project", cfg: getProviderConfig(provider, cwd, home, false) });
159
+ if (!getProviderConfig(provider, cwd, home, false).globalOnly) {
160
+ scopes.push({ label: "user", cfg: getProviderConfig(provider, cwd, home, true) });
161
+ }
162
+ // For claude, also check ~/.mcp.json (user-scope global config)
163
+ if (provider === "claude") {
164
+ const userMcpJson = join(home, ".mcp.json");
165
+ scopes.push({
166
+ label: "user (~/.mcp.json)",
167
+ cfg: { configPath: userMcpJson, mcpKey: "pairai-channel", format: "json" as const, globalOnly: true, instruction: "" },
168
+ });
169
+ }
170
+
171
+ for (const { label, cfg } of scopes) {
172
+ if (!existsSync(cfg.configPath)) continue;
173
+
174
+ try {
175
+ if (cfg.format === "toml") {
176
+ const content = readFileSync(cfg.configPath, "utf-8");
177
+ // Remove the TOML block: [mcp_servers.<key>] through next section or EOF
178
+ const sectionHeader = `[mcp_servers.${cfg.mcpKey}]`;
179
+ if (!content.includes(sectionHeader)) continue;
180
+
181
+ // Extract credentials before removing
182
+ const hubMatch = content.match(/PAIRAI_HUB_URL\s*=\s*"([^"]+)"/);
183
+ const keyMatch = content.match(/PAIRAI_AGENT_CRED\s*=\s*"([^"]+)"/);
184
+ const pemMatch = content.match(/PAIRAI_KEY_FILE\s*=\s*"([^"]+)"/);
185
+
186
+ // Save recovery file
187
+ if (keyMatch && pemMatch) {
188
+ const agentId = pemMatch[1]!.split("/").pop()?.replace(".pem", "") ?? "unknown";
189
+ saveRecovery(agentId, hubMatch?.[1] ?? "https://pairai.pro", keyMatch[1]!, pemMatch[1]!);
190
+ savedCredentials = true;
191
+ }
192
+
193
+ // Remove the section
194
+ const regex = new RegExp(`\\n?\\[mcp_servers\\.${cfg.mcpKey}\\][\\s\\S]*?(?=\\n\\[|$)`, "g");
195
+ const cleaned = content.replace(regex, "").trim();
196
+ if (cleaned) {
197
+ writeFileSync(cfg.configPath, cleaned + "\n");
198
+ } else {
199
+ // Config file is now empty — remove it
200
+ const { unlinkSync } = await import("node:fs");
201
+ unlinkSync(cfg.configPath);
202
+ }
203
+ console.log(` Removed from ${label}: ${cfg.configPath}`);
204
+ removed++;
205
+ } else {
206
+ // JSON config
207
+ const content = readFileSync(cfg.configPath, "utf-8");
208
+ const parsed = JSON.parse(content);
209
+ const servers = parsed.mcpServers ?? parsed.mcp_servers ?? {};
210
+ if (!servers[cfg.mcpKey]) continue;
211
+
212
+ // Extract credentials before removing
213
+ const entry = servers[cfg.mcpKey];
214
+ const env = entry.env ?? {};
215
+ const hubUrl = env.PAIRAI_HUB_URL ?? env.PAIRAI_URL ?? "https://pairai.pro";
216
+ const apiKey = env.PAIRAI_AGENT_CRED ?? env.PAIRAI_API_KEY;
217
+ const keyFile = env.PAIRAI_KEY_FILE ?? env.PAIRAI_PRIVATE_KEY_PATH;
218
+
219
+ if (apiKey && keyFile) {
220
+ const agentId = keyFile.split("/").pop()?.replace(".pem", "") ?? "unknown";
221
+ saveRecovery(agentId, hubUrl, apiKey, keyFile);
222
+ savedCredentials = true;
223
+ }
224
+
225
+ // Remove the entry
226
+ delete servers[cfg.mcpKey];
227
+
228
+ // If mcpServers is now empty, remove it too
229
+ const serverKey = parsed.mcpServers ? "mcpServers" : "mcp_servers";
230
+ if (Object.keys(servers).length === 0) {
231
+ delete parsed[serverKey];
232
+ }
233
+
234
+ if (Object.keys(parsed).length === 0) {
235
+ const { unlinkSync } = await import("node:fs");
236
+ unlinkSync(cfg.configPath);
237
+ console.log(` Removed (empty): ${cfg.configPath}`);
238
+ } else {
239
+ writeFileSync(cfg.configPath, JSON.stringify(parsed, null, 2) + "\n");
240
+ console.log(` Removed from ${label}: ${cfg.configPath}`);
241
+ }
242
+ removed++;
243
+ }
244
+ } catch (err) {
245
+ console.error(` Warning: Could not clean ${cfg.configPath}: ${(err as Error).message}`);
246
+ }
247
+ }
248
+
249
+ // Clean up lock files
250
+ const lockDir = join(home, ".pairai", "locks");
251
+ if (existsSync(lockDir)) {
252
+ try {
253
+ const { readdirSync, unlinkSync: unlinkLock } = await import("node:fs");
254
+ for (const f of readdirSync(lockDir)) {
255
+ unlinkLock(join(lockDir, f));
256
+ }
257
+ console.log(` Cleaned lock files: ${lockDir}`);
258
+ } catch {}
259
+ }
260
+
261
+ // Optionally delete agent from hub
262
+ if (deleteAgent) {
263
+ // Read the recovery file to get credentials
264
+ const recoveryDir = join(home, ".pairai", "agents");
265
+ if (existsSync(recoveryDir)) {
266
+ const { readdirSync: readDir } = await import("node:fs");
267
+ for (const f of readDir(recoveryDir)) {
268
+ if (!f.endsWith(".json")) continue;
269
+ try {
270
+ const recovery = JSON.parse(readFileSync(join(recoveryDir, f), "utf-8"));
271
+ console.log(`\n Deleting agent ${f.replace(".json", "")} from ${recovery.hubUrl}...`);
272
+ const res = await fetch(`${recovery.hubUrl}/agents/me`, {
273
+ method: "DELETE",
274
+ headers: { Authorization: `Bearer ${recovery.apiKey}` },
275
+ });
276
+ if (res.ok) {
277
+ console.log(` Agent deleted from hub.`);
278
+ } else {
279
+ console.log(` Could not delete: ${res.status} ${await res.text()}`);
280
+ }
281
+ } catch (err) {
282
+ console.error(` Warning: ${(err as Error).message}`);
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ if (removed === 0) {
289
+ console.log(" No pairai config found to remove.");
290
+ }
291
+
292
+ console.log();
293
+ if (savedCredentials) {
294
+ console.log(` Credentials saved to ~/.pairai/agents/ (for re-registration without new setup)`);
295
+ }
296
+ console.log(` Private keys preserved in ~/.pairai/keys/ (never auto-deleted)`);
297
+ if (!deleteAgent) {
298
+ console.log(` Agent still registered on hub. To also delete: npx pairai uninstall --delete-agent`);
299
+ }
300
+ console.log();
301
+ process.exit(0);
302
+ }
303
+
304
+ function saveRecovery(agentId: string, hubUrl: string, apiKey: string, keyFile: string) {
305
+ const dir = join(homedir(), ".pairai", "agents");
306
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
307
+ const recoveryPath = join(dir, `${agentId}.json`);
308
+ if (existsSync(recoveryPath)) return; // don't overwrite existing recovery
309
+ writeFileSync(recoveryPath, JSON.stringify({ hubUrl, apiKey, keyFile, savedAt: new Date().toISOString() }, null, 2) + "\n", { mode: 0o600 });
310
+ }
311
+
83
312
  // ── Setup: register + configure ──────────────────────────────────────────────
84
313
 
85
314
  if (command === "setup") {
@@ -108,16 +337,39 @@ if (command === "setup") {
108
337
  process.exit(1);
109
338
  }
110
339
  }
111
- const provider = (providerArg as Provider) ?? detectProvider();
340
+ let provider: Provider;
341
+ if (providerArg) {
342
+ provider = providerArg as Provider;
343
+ } else {
344
+ const detected = detectProvider();
345
+ if (detected) {
346
+ provider = detected;
347
+ } else if (process.stdin.isTTY) {
348
+ provider = await select({
349
+ message: "Select your AI tool:",
350
+ choices: PROVIDER_CHOICES,
351
+ });
352
+ } else {
353
+ console.error('Cannot auto-detect provider. Use --provider flag (e.g. npx pairai setup "My Agent" --provider cursor)');
354
+ process.exit(1);
355
+ }
356
+ }
112
357
  const globalIdx = rest.indexOf("--global");
113
358
  const useGlobal = globalIdx !== -1 ? (rest.splice(globalIdx, 1), true) : false;
114
- const agentName = rest.find((a) => !a.startsWith("--"));
359
+ let agentName = rest.find((a) => !a.startsWith("--"));
115
360
 
116
361
  const forceIdx = rest.indexOf("--force");
117
362
  const useForce = forceIdx !== -1 ? (rest.splice(forceIdx, 1), true) : false;
118
363
  if (!agentName) {
119
- console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
120
- process.exit(1);
364
+ if (process.stdin.isTTY) {
365
+ agentName = await input({
366
+ message: 'What should we call your agent? Other agents and users will see this name. (e.g. "Alice\'s Assistant", "Travel Bot")',
367
+ validate: (v) => v.trim().length > 0 && v.trim().length <= 64 ? true : "Name must be 1-64 characters",
368
+ });
369
+ } else {
370
+ console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
371
+ process.exit(1);
372
+ }
121
373
  }
122
374
 
123
375
  // Check for existing config to avoid accidental overwrites
@@ -226,6 +478,7 @@ if (command !== "serve") {
226
478
  console.error("Usage:");
227
479
  console.error(' npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
228
480
  console.error(" npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]");
481
+ console.error(" npx pairai uninstall [--provider ...] [--delete-agent] — remove MCP config, preserve keys");
229
482
  console.error(" npx pairai upgrade — update to latest version");
230
483
  console.error(" npx pairai version — show current version");
231
484
  console.error("");
@@ -289,7 +542,10 @@ const API_PREFIX = "/api/v1";
289
542
 
290
543
  async function hubGet(path: string) {
291
544
  const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, { headers, signal: AbortSignal.timeout(HUB_TIMEOUT_MS) });
292
- if (!res.ok) throw new Error(`GET ${path}: ${res.status}`);
545
+ if (!res.ok) {
546
+ const body = await res.json().catch(() => ({})) as { error?: string };
547
+ throw new Error(body.error ?? `GET ${path}: ${res.status}`);
548
+ }
293
549
  return res.json();
294
550
  }
295
551
 
@@ -300,7 +556,10 @@ async function hubPost(path: string, body?: unknown) {
300
556
  body: body ? JSON.stringify(body) : undefined,
301
557
  signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
302
558
  });
303
- if (!res.ok) throw new Error(`POST ${path}: ${res.status}`);
559
+ if (!res.ok) {
560
+ const respBody = await res.json().catch(() => ({})) as { error?: string };
561
+ throw new Error(respBody.error ?? `POST ${path}: ${res.status}`);
562
+ }
304
563
  return res.json();
305
564
  }
306
565
 
@@ -342,7 +601,9 @@ async function loadAgentInfo() {
342
601
  const me = (await hubGet("/agents/me")) as { id: string; name: string; publicKey?: string };
343
602
  myAgentId = me.id;
344
603
  myPublicKey = me.publicKey ?? "";
345
- } catch {}
604
+ } catch (err) {
605
+ console.error("[pairai] failed to load agent info:", (err as Error).message);
606
+ }
346
607
  }
347
608
 
348
609
  async function loadPublicKeys() {
@@ -351,7 +612,9 @@ async function loadPublicKeys() {
351
612
  for (const c of conns) {
352
613
  if (c.publicKey) pubKeyCache.set(c.agentId, c.publicKey);
353
614
  }
354
- } catch {}
615
+ } catch (err) {
616
+ console.error("[pairai] failed to load public keys:", (err as Error).message);
617
+ }
355
618
  }
356
619
 
357
620
  function localEncrypt(plaintext: string, taskId: string, recipientPubKeys: Record<string, string>) {
@@ -377,6 +640,13 @@ const instructions = [
377
640
  "The channel server polls for updates automatically — you don't need to poll manually.",
378
641
  "When the user asks about updates, new messages, or pending work, use pairai_check_updates (not pairai_list_tasks).",
379
642
  "",
643
+ "Connecting with other agents:",
644
+ " - To find agents: use pairai_discover_agents (search by name, description, or capability tag)",
645
+ " - To connect: use pairai_connect_directly with the agent's ID (works instantly if they have autoAccept)",
646
+ " - To collaborate: use pairai_create_task to send work, then pairai_reply to exchange messages",
647
+ " - The full flow is: discover → connect → create task → exchange messages → complete",
648
+ " - Featured agents on the hub: Reviewer (code/spec review), Artist (image generation), Polyglot (translation)",
649
+ "",
380
650
  "Notification attributes:",
381
651
  " task_id — the task this message belongs to",
382
652
  " task_title — short description of the task",
@@ -477,6 +747,17 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
477
747
  required: ["code"],
478
748
  },
479
749
  },
750
+ {
751
+ name: "pairai_connect_directly",
752
+ 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.",
753
+ inputSchema: {
754
+ type: "object" as const,
755
+ properties: {
756
+ agent_id: { type: "string", description: "ID of the agent to connect with" },
757
+ },
758
+ required: ["agent_id"],
759
+ },
760
+ },
480
761
  {
481
762
  name: "pairai_update_profile",
482
763
  description: "Update your agent's profile — name, description, capabilities, and metadata. Returns the updated profile.",
@@ -488,6 +769,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
488
769
  capabilities: { type: "array", items: { type: "string" }, description: "List of capabilities, e.g. ['scheduling', 'code-review']" },
489
770
  metadata: { type: "object", description: "Arbitrary JSON metadata (max 4KB)" },
490
771
  discoverable: { type: "boolean", description: "Whether to appear in the public agent directory" },
772
+ autoAccept: { type: "boolean", description: "Whether to accept direct connections without pairing codes" },
491
773
  defaultApprovalRule: { type: "string", enum: ["auto", "require"], description: "Default approval rule for new connections" },
492
774
  },
493
775
  },
@@ -639,6 +921,90 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
639
921
  required: ["target_agent_id", "title"],
640
922
  },
641
923
  },
924
+ {
925
+ name: "pairai_delete_message",
926
+ description: "Delete (tombstone) a message you sent. The message content is replaced with [deleted].",
927
+ inputSchema: {
928
+ type: "object" as const,
929
+ properties: {
930
+ task_id: { type: "string", description: "Task ID" },
931
+ message_id: { type: "string", description: "Message ID to delete" },
932
+ },
933
+ required: ["task_id", "message_id"],
934
+ },
935
+ },
936
+ {
937
+ name: "pairai_delete_file",
938
+ description: "Delete a file you uploaded. Removes from disk and tombstones the associated message.",
939
+ inputSchema: {
940
+ type: "object" as const,
941
+ properties: {
942
+ file_id: { type: "string", description: "File ID to delete" },
943
+ },
944
+ required: ["file_id"],
945
+ },
946
+ },
947
+ {
948
+ name: "pairai_delete_task",
949
+ description: "Permanently delete a terminal task (completed, failed, cancelled) and all its messages and files.",
950
+ inputSchema: {
951
+ type: "object" as const,
952
+ properties: {
953
+ task_id: { type: "string", description: "Task ID to delete" },
954
+ },
955
+ required: ["task_id"],
956
+ },
957
+ },
958
+ {
959
+ name: "pairai_rotate_api_key",
960
+ description: "Generate a new API key. WARNING: old key immediately invalidated. Save the new key before doing anything else.",
961
+ inputSchema: {
962
+ type: "object" as const,
963
+ properties: {},
964
+ },
965
+ },
966
+ {
967
+ name: "pairai_delete_account",
968
+ description: "PERMANENTLY delete your agent and ALL associated data. IRREVERSIBLE.",
969
+ inputSchema: {
970
+ type: "object" as const,
971
+ properties: {},
972
+ },
973
+ },
974
+ {
975
+ name: "pairai_report_usage",
976
+ description: "Report API cost for a task. Deducts from the initiator's credits. Only the target agent (specialist) can call this.",
977
+ inputSchema: {
978
+ type: "object" as const,
979
+ properties: {
980
+ task_id: { type: "string", description: "Task ID" },
981
+ cost: { type: "number", description: "Cost in USD (e.g. 0.0023)" },
982
+ },
983
+ required: ["task_id", "cost"],
984
+ },
985
+ },
986
+ {
987
+ name: "pairai_block_agent",
988
+ description: "Block an agent. They cannot discover or connect with you. Disconnects if connected.",
989
+ inputSchema: {
990
+ type: "object" as const,
991
+ properties: {
992
+ agent_id: { type: "string", description: "ID of the agent to block" },
993
+ },
994
+ required: ["agent_id"],
995
+ },
996
+ },
997
+ {
998
+ name: "pairai_unblock_agent",
999
+ description: "Unblock a previously blocked agent.",
1000
+ inputSchema: {
1001
+ type: "object" as const,
1002
+ properties: {
1003
+ agent_id: { type: "string", description: "ID of the agent to unblock" },
1004
+ },
1005
+ required: ["agent_id"],
1006
+ },
1007
+ },
642
1008
  ],
643
1009
  }));
644
1010
 
@@ -742,11 +1108,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
742
1108
  await hubPatch(`/tasks/${args.task_id}`, { status: args.status });
743
1109
  return { content: [{ type: "text" as const, text: `Status → ${args.status}` }] };
744
1110
  } 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;
1111
+ return { content: [{ type: "text" as const, text: `Cannot update status: ${(err as Error).message}` }], isError: true };
750
1112
  }
751
1113
  }
752
1114
 
@@ -809,6 +1171,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
809
1171
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
810
1172
  }
811
1173
 
1174
+ if (name === "pairai_connect_directly") {
1175
+ const { agent_id } = args as { agent_id: string };
1176
+ const data = await hubPost(`/connect/${agent_id}`);
1177
+ // Refresh public keys after new connection
1178
+ await loadPublicKeys();
1179
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
1180
+ }
1181
+
812
1182
  if (name === "pairai_update_profile") {
813
1183
  const body: Record<string, unknown> = {};
814
1184
  if (args.name !== undefined) body.name = args.name;
@@ -816,6 +1186,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
816
1186
  if (args.capabilities !== undefined) body.capabilities = args.capabilities;
817
1187
  if (args.metadata !== undefined) body.metadata = args.metadata;
818
1188
  if (args.discoverable !== undefined) body.discoverable = args.discoverable === "true" || args.discoverable === true;
1189
+ if (args.autoAccept !== undefined) body.autoAccept = args.autoAccept === "true" || args.autoAccept === true;
819
1190
  if (args.defaultApprovalRule !== undefined) body.defaultApprovalRule = args.defaultApprovalRule;
820
1191
  const data = await hubPatch("/agents/me", body);
821
1192
  return { content: [{ type: "text" as const, text: "Profile updated.\n" + JSON.stringify(data, null, 2) }] };
@@ -880,12 +1251,12 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
880
1251
 
881
1252
  if (name === "pairai_list_tasks") {
882
1253
  await loadPublicKeys();
883
- const data = (await hubGet("/tasks")) as Array<{
1254
+ const qs = args.status ? `?status=${args.status}` : "";
1255
+ const data = (await hubGet(`/tasks${qs}`)) as Array<{
884
1256
  id: string; status: string; title: string; encrypted?: boolean;
885
1257
  description?: string; descriptionKeys?: any; senderSignature?: string; initiatorAgentId?: string;
886
1258
  }>;
887
- const filtered = args.status ? data.filter((t) => t.status === args.status) : data;
888
- const decrypted = filtered.map((t) => {
1259
+ const decrypted = data.map((t) => {
889
1260
  if (t.encrypted) {
890
1261
  const desc = decryptTaskDescription(t, t.id);
891
1262
  return { ...t, title: desc.split("\n")[0] || t.title, description: desc };
@@ -915,6 +1286,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
915
1286
  }
916
1287
  const decryptedMsgs = msgs.map((m) => {
917
1288
  if (data.encrypted) {
1289
+ // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1290
+ if (m.contentType === "encrypted" && m.encryptedKeys && m.content && m.content.length < 30 && !/[/+=]/.test(m.content)) {
1291
+ return { ...m, content: `[Encrypted file — use pairai_download_file with task_id: "${data.id}", file_id: "${m.content}"]`, contentType: "file" };
1292
+ }
918
1293
  try {
919
1294
  const d = decryptMessage(m, data.id);
920
1295
  return { ...m, content: d.content, contentType: d.contentType };
@@ -1112,6 +1487,84 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1112
1487
  return { content: [{ type: "text" as const, text: `Encrypted task created. ID: ${taskId}` }] };
1113
1488
  }
1114
1489
 
1490
+ if (name === "pairai_delete_message") {
1491
+ const { task_id, message_id } = args as { task_id: string; message_id: string };
1492
+ try {
1493
+ await hubDelete(`/tasks/${task_id}/messages/${message_id}`);
1494
+ return { content: [{ type: "text" as const, text: "Message deleted." }] };
1495
+ } catch (err) {
1496
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1497
+ }
1498
+ }
1499
+
1500
+ if (name === "pairai_delete_file") {
1501
+ const { file_id } = args as { file_id: string };
1502
+ try {
1503
+ await hubDelete(`/files/${file_id}`);
1504
+ return { content: [{ type: "text" as const, text: "File deleted." }] };
1505
+ } catch (err) {
1506
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1507
+ }
1508
+ }
1509
+
1510
+ if (name === "pairai_delete_task") {
1511
+ const { task_id } = args as { task_id: string };
1512
+ try {
1513
+ const result = (await hubDelete(`/tasks/${task_id}`)) as { deletedMessages?: number; deletedFiles?: number };
1514
+ return { content: [{ type: "text" as const, text: `Task deleted. ${result.deletedMessages ?? 0} message(s) and ${result.deletedFiles ?? 0} file(s) removed.` }] };
1515
+ } catch (err) {
1516
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1517
+ }
1518
+ }
1519
+
1520
+ if (name === "pairai_rotate_api_key") {
1521
+ try {
1522
+ const result = (await hubPost("/agents/me/rotate-key")) as { apiKey: string };
1523
+ 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.` }] };
1524
+ } catch (err) {
1525
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1526
+ }
1527
+ }
1528
+
1529
+ if (name === "pairai_delete_account") {
1530
+ try {
1531
+ await hubDelete("/agents/me");
1532
+ return { content: [{ type: "text" as const, text: "Account deleted." }] };
1533
+ } catch (err) {
1534
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1535
+ }
1536
+ }
1537
+
1538
+ if (name === "pairai_report_usage") {
1539
+ const { task_id, cost } = args as { task_id: string; cost: number };
1540
+ try {
1541
+ const result = await hubPost(`/tasks/${task_id}/usage`, { cost });
1542
+ return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
1543
+ } catch (err) {
1544
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1545
+ }
1546
+ }
1547
+
1548
+ if (name === "pairai_block_agent") {
1549
+ const { agent_id } = args as { agent_id: string };
1550
+ try {
1551
+ await hubPost("/agents/me/block", { agentId: agent_id });
1552
+ return { content: [{ type: "text" as const, text: "Agent blocked." }] };
1553
+ } catch (err) {
1554
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1555
+ }
1556
+ }
1557
+
1558
+ if (name === "pairai_unblock_agent") {
1559
+ const { agent_id } = args as { agent_id: string };
1560
+ try {
1561
+ await hubDelete(`/agents/me/block/${agent_id}`);
1562
+ return { content: [{ type: "text" as const, text: "Agent unblocked." }] };
1563
+ } catch (err) {
1564
+ return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
1565
+ }
1566
+ }
1567
+
1115
1568
  throw new Error(`Unknown tool: ${name}`);
1116
1569
  });
1117
1570
 
@@ -1127,6 +1580,12 @@ function decryptMessage(
1127
1580
  if (msg.contentType !== "encrypted" || !msg.encryptedKeys || !msg.senderSignature || !PRIVATE_KEY) {
1128
1581
  return { content: msg.content, contentType: msg.contentType };
1129
1582
  }
1583
+ // Encrypted file messages: content is a file ID (nanoid), not ciphertext.
1584
+ // The signature covers the encrypted file data on disk, not the file ID reference.
1585
+ // Don't attempt to decrypt — the file is retrieved and decrypted via download_file.
1586
+ if (msg.content && msg.content.length < 30 && !/[/+=]/.test(msg.content)) {
1587
+ return { content: `[Encrypted file attachment — file_id: ${msg.content}]`, contentType: "file" };
1588
+ }
1130
1589
  try {
1131
1590
  const keys = typeof msg.encryptedKeys === "string" ? JSON.parse(msg.encryptedKeys) : msg.encryptedKeys;
1132
1591
  const senderPub = msg.senderAgentId === myAgentId ? myPublicKey : pubKeyCache.get(msg.senderAgentId);
@@ -1207,7 +1666,7 @@ async function poll() {
1207
1666
  const decryptedMessages = (taskMsgs ?? []).map((m) => {
1208
1667
  // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1209
1668
  if (m.contentType === "encrypted" && m.encryptedKeys && m.content.length < 30) {
1210
- return "[File attachment — use pairai_download_file to retrieve]";
1669
+ return `[File attachment — use pairai_download_file with task_id: "${task.id}", file_id: "${m.content}"]`;
1211
1670
  }
1212
1671
  try {
1213
1672
  const d = decryptMessage(m, task.id);
@@ -1260,7 +1719,7 @@ async function poll() {
1260
1719
  const isEncryptedFile = msg.contentType === "encrypted" && msg.encryptedKeys && msg.content.length < 30;
1261
1720
  let decrypted: { content: string; contentType: string };
1262
1721
  if (isEncryptedFile) {
1263
- decrypted = { content: "[File attachment — use pairai_download_file to retrieve]", contentType: "text" };
1722
+ decrypted = { content: `[File attachment — use pairai_download_file with task_id: "${unread.taskId}", file_id: "${msg.content}"]`, contentType: "text" };
1264
1723
  } else {
1265
1724
  try {
1266
1725
  decrypted = decryptMessage(msg, unread.taskId);