pairai 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib.ts +183 -14
  2. package/package.json +1 -1
  3. package/pairai.ts +282 -101
package/lib.ts CHANGED
@@ -2,29 +2,131 @@
2
2
  * Pure/testable functions extracted from pairai CLI.
3
3
  * Imported by both pairai.ts and unit tests.
4
4
  */
5
- import { existsSync, readFileSync, statSync } from "node:fs";
5
+ import { existsSync, readFileSync, statSync, openSync, writeSync, closeSync, unlinkSync, mkdirSync, constants as fsConstants } from "node:fs";
6
6
  import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ export type Provider = "claude" | "gemini" | "cursor" | "copilot" | "windsurf" | "codex" | "amazonq";
10
+
11
+ const VALID_PROVIDERS: Provider[] = ["claude", "gemini", "cursor", "copilot", "windsurf", "codex", "amazonq"];
7
12
 
8
13
  /**
9
14
  * Validate the --provider flag value.
10
15
  * Returns the validated provider or throws with a message.
11
16
  */
12
- export function validateProvider(value: string): "claude" | "gemini" {
13
- if (value !== "claude" && value !== "gemini") {
14
- throw new Error(`Unknown provider "${value}". Must be "claude" or "gemini".`);
17
+ export function validateProvider(value: string): Provider {
18
+ if (!VALID_PROVIDERS.includes(value as Provider)) {
19
+ throw new Error(`Unknown provider "${value}". Must be one of: ${VALID_PROVIDERS.join(", ")}.`);
15
20
  }
16
- return value;
21
+ return value as Provider;
17
22
  }
18
23
 
19
24
  /**
20
25
  * Auto-detect provider based on environment and filesystem.
21
26
  */
22
- export function detectProvider(): "claude" | "gemini" {
27
+ export function detectProvider(): Provider {
23
28
  if (process.env.GEMINI_CLI) return "gemini";
29
+ try { if (statSync(".cursor").isDirectory()) return "cursor"; } catch {}
30
+ try { if (statSync(".windsurf").isDirectory()) return "windsurf"; } catch {}
31
+ try { if (statSync(".vscode").isDirectory()) return "copilot"; } catch {}
32
+ try { if (statSync(".codex").isDirectory()) return "codex"; } catch {}
33
+ try { if (statSync(".amazonq").isDirectory()) return "amazonq"; } catch {}
24
34
  try { if (statSync(".gemini").isDirectory()) return "gemini"; } catch {}
25
35
  return "claude";
26
36
  }
27
37
 
38
+ export interface ProviderConfig {
39
+ /** Config file path (project-level or global) */
40
+ configPath: string;
41
+ /** MCP server key name in the config */
42
+ mcpKey: string;
43
+ /** Format: "json" or "toml" */
44
+ format: "json" | "toml";
45
+ /** Whether this provider only supports global config */
46
+ globalOnly: boolean;
47
+ /** Post-setup instruction */
48
+ instruction: string;
49
+ }
50
+
51
+ /**
52
+ * Get the config file path, format, and setup instructions for a provider.
53
+ */
54
+ export function getProviderConfig(
55
+ provider: Provider,
56
+ cwd: string,
57
+ homeDir: string,
58
+ useGlobal: boolean,
59
+ ): ProviderConfig {
60
+ switch (provider) {
61
+ case "claude":
62
+ return {
63
+ configPath: join(cwd, ".mcp.json"),
64
+ mcpKey: "pairai-channel",
65
+ format: "json",
66
+ globalOnly: false,
67
+ instruction: "Start Claude Code in this directory",
68
+ };
69
+ case "gemini": {
70
+ const dir = useGlobal ? join(homeDir, ".gemini") : join(cwd, ".gemini");
71
+ return {
72
+ configPath: join(dir, "settings.json"),
73
+ mcpKey: "pairai",
74
+ format: "json",
75
+ globalOnly: false,
76
+ instruction: "Restart Gemini CLI to activate the pairai MCP server",
77
+ };
78
+ }
79
+ case "cursor": {
80
+ const dir = useGlobal ? join(homeDir, ".cursor") : join(cwd, ".cursor");
81
+ return {
82
+ configPath: join(dir, "mcp.json"),
83
+ mcpKey: "pairai",
84
+ format: "json",
85
+ globalOnly: false,
86
+ instruction: "Restart Cursor to activate the pairai MCP server",
87
+ };
88
+ }
89
+ case "copilot":
90
+ return {
91
+ configPath: join(cwd, ".vscode", "mcp.json"),
92
+ mcpKey: "pairai",
93
+ format: "json",
94
+ globalOnly: false,
95
+ instruction: "Reload VS Code window (Ctrl+Shift+P → Developer: Reload Window)",
96
+ };
97
+ case "windsurf":
98
+ return {
99
+ configPath: join(homeDir, ".codeium", "windsurf", "mcp_config.json"),
100
+ mcpKey: "pairai",
101
+ format: "json",
102
+ globalOnly: true,
103
+ instruction: "Restart Windsurf to activate the pairai MCP server",
104
+ };
105
+ case "codex": {
106
+ const dir = useGlobal ? join(homeDir, ".codex") : join(cwd, ".codex");
107
+ return {
108
+ configPath: join(dir, "config.toml"),
109
+ mcpKey: "pairai",
110
+ format: "toml",
111
+ globalOnly: false,
112
+ instruction: "Restart Codex CLI to activate the pairai MCP server",
113
+ };
114
+ }
115
+ case "amazonq": {
116
+ const path = useGlobal
117
+ ? join(homeDir, ".aws", "amazonq", "default.json")
118
+ : join(cwd, ".amazonq", "default.json");
119
+ return {
120
+ configPath: path,
121
+ mcpKey: "pairai",
122
+ format: "json",
123
+ globalOnly: false,
124
+ instruction: "Restart Amazon Q to activate the pairai MCP server",
125
+ };
126
+ }
127
+ }
128
+ }
129
+
28
130
  /**
29
131
  * Replace any `pairai@x.y.z` version pin in a config string with a new version.
30
132
  */
@@ -37,21 +139,26 @@ export function updateVersionInConfig(content: string, latest: string): string {
37
139
  * Returns the path if config exists with a pairai entry, null otherwise.
38
140
  */
39
141
  export function checkExistingConfig(
40
- provider: "claude" | "gemini",
142
+ provider: Provider,
41
143
  cwd: string,
42
144
  homeDir: string,
43
145
  useGlobal: boolean,
44
146
  ): string | null {
45
- const configPath = provider === "gemini"
46
- ? join(useGlobal ? join(homeDir, ".gemini") : join(cwd, ".gemini"), "settings.json")
47
- : join(cwd, ".mcp.json");
48
- const mcpKey = provider === "gemini" ? "pairai" : "pairai-channel";
147
+ const cfg = getProviderConfig(provider, cwd, homeDir, useGlobal);
148
+ if (!existsSync(cfg.configPath)) return null;
149
+
150
+ if (cfg.format === "toml") {
151
+ try {
152
+ const content = readFileSync(cfg.configPath, "utf-8");
153
+ if (content.includes(`[mcp_servers.${cfg.mcpKey}]`)) return cfg.configPath;
154
+ } catch {}
155
+ return null;
156
+ }
49
157
 
50
- if (!existsSync(configPath)) return null;
51
158
  try {
52
- const existing = JSON.parse(readFileSync(configPath, "utf-8"));
159
+ const existing = JSON.parse(readFileSync(cfg.configPath, "utf-8"));
53
160
  const servers = existing.mcpServers ?? {};
54
- if (servers[mcpKey]) return configPath;
161
+ if (servers[cfg.mcpKey]) return cfg.configPath;
55
162
  } catch {}
56
163
  return null;
57
164
  }
@@ -78,3 +185,65 @@ export function formatKeyBackupBox(keyPath: string): string[] {
78
185
  out.push(` \u2514${"\u2500".repeat(w + 2)}\u2518`);
79
186
  return out;
80
187
  }
188
+
189
+ // ── Polling lock ─────────────────────────────────────────────────────────────
190
+
191
+ const STALE_THRESHOLD_MS = 60_000; // 60 seconds
192
+
193
+ function lockPath(agentId: string, lockDir?: string): string {
194
+ const dir = lockDir ?? join(homedir(), ".pairai", "locks");
195
+ mkdirSync(dir, { recursive: true });
196
+ return join(dir, `${agentId}.lock`);
197
+ }
198
+
199
+ /**
200
+ * Try to acquire an exclusive lock for this agent.
201
+ * Uses atomic O_CREAT|O_EXCL file creation + stale lock detection.
202
+ * Returns true if lock acquired, false if another live process holds it.
203
+ */
204
+ export function acquireLock(agentId: string, lockDir?: string): boolean {
205
+ const path = lockPath(agentId, lockDir);
206
+
207
+ for (let attempt = 0; attempt < 2; attempt++) {
208
+ try {
209
+ const fd = openSync(path, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY);
210
+ writeSync(fd, String(process.pid));
211
+ closeSync(fd);
212
+ return true;
213
+ } catch (err: any) {
214
+ if (err.code !== "EEXIST") throw err;
215
+
216
+ // Lock file exists — check if holder is alive and not stale
217
+ try {
218
+ const pid = parseInt(readFileSync(path, "utf-8").trim(), 10);
219
+ const stat = statSync(path);
220
+ const age = Date.now() - stat.mtimeMs;
221
+
222
+ // If PID is alive and lock is fresh, we can't acquire
223
+ if (!isNaN(pid) && age < STALE_THRESHOLD_MS) {
224
+ try {
225
+ process.kill(pid, 0); // signal 0 = check if alive
226
+ return false; // process is alive, lock is valid
227
+ } catch {
228
+ // process is dead — reclaim
229
+ }
230
+ }
231
+
232
+ // Stale or dead process — remove and retry
233
+ unlinkSync(path);
234
+ } catch {
235
+ // Can't read/stat lock file — try to remove and retry
236
+ try { unlinkSync(path); } catch {}
237
+ }
238
+ }
239
+ }
240
+ return false;
241
+ }
242
+
243
+ /**
244
+ * Release the lock for this agent. Safe to call multiple times.
245
+ */
246
+ export function releaseLock(agentId: string, lockDir?: string): void {
247
+ const path = lockPath(agentId, lockDir);
248
+ try { unlinkSync(path); } catch {}
249
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pairai",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "pairai CLI — connect AI agents to collaborate via the pairai hub",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/pairai.ts CHANGED
@@ -3,8 +3,8 @@
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
  *
@@ -17,11 +17,12 @@ import {
17
17
  publicEncrypt, privateDecrypt, sign, verify,
18
18
  randomBytes, createCipheriv, createDecipheriv, constants,
19
19
  } from "node:crypto";
20
- import { writeFileSync, mkdirSync, readFileSync, statSync, existsSync } from "node:fs";
20
+ import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
21
21
  import { homedir } from "node:os";
22
22
  import { join, dirname } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
- import { validateProvider, detectProvider, updateVersionInConfig, checkExistingConfig, formatKeyBackupBox } from "./lib.js";
24
+ import { validateProvider, detectProvider, checkExistingConfig, formatKeyBackupBox, acquireLock, releaseLock, getProviderConfig } from "./lib.js";
25
+ import type { Provider } from "./lib.js";
25
26
 
26
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
28
  const PKG = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
@@ -49,27 +50,11 @@ if (command === "upgrade") {
49
50
  } else {
50
51
  console.log(` New version available: v${latest}`);
51
52
  console.log(` Upgrading...\n`);
53
+ // Clear npx cache so next `npx pairai serve` picks up the new version
54
+ try { execSync("npx clear-npx-cache 2>/dev/null || rm -rf " + join(homedir(), ".npm/_npx"), { stdio: "pipe" }); } catch {}
52
55
  execSync("npm install -g pairai@latest", { stdio: "inherit" });
53
56
  console.log(`\n Upgraded to v${latest}.`);
54
57
  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
58
  }
74
59
  } catch (err) {
75
60
  console.error(` Upgrade failed: ${(err as Error).message}`);
@@ -78,7 +63,7 @@ if (command === "upgrade") {
78
63
  process.exit(0);
79
64
  }
80
65
 
81
- // detectProvider, validateProvider, updateVersionInConfig, checkExistingConfig,
66
+ // detectProvider, validateProvider, checkExistingConfig,
82
67
  // formatKeyBackupBox are imported from ./lib.js
83
68
 
84
69
  // ── Setup: register + configure ──────────────────────────────────────────────
@@ -95,7 +80,7 @@ if (command === "setup") {
95
80
  process.exit(1);
96
81
  }
97
82
  }
98
- const provider = (providerArg as "claude" | "gemini") ?? detectProvider();
83
+ const provider = (providerArg as Provider) ?? detectProvider();
99
84
  const globalIdx = rest.indexOf("--global");
100
85
  const useGlobal = globalIdx !== -1 ? (rest.splice(globalIdx, 1), true) : false;
101
86
  const agentName = rest.find((a) => !a.startsWith("--"));
@@ -103,7 +88,7 @@ if (command === "setup") {
103
88
  const forceIdx = rest.indexOf("--force");
104
89
  const useForce = forceIdx !== -1 ? (rest.splice(forceIdx, 1), true) : false;
105
90
  if (!agentName) {
106
- console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini] [--global] [--force]');
91
+ console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
107
92
  process.exit(1);
108
93
  }
109
94
 
@@ -152,61 +137,54 @@ if (command === "setup") {
152
137
  for (const line of formatKeyBackupBox(keyPath)) console.log(line);
153
138
  console.log();
154
139
 
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
140
+ const cfg = getProviderConfig(provider, process.cwd(), homedir(), useGlobal);
141
+ const serverEntry = {
142
+ command: "npx",
143
+ args: ["pairai", "serve"],
144
+ env: {
145
+ PAIRAI_HUB_URL: hubUrl,
146
+ PAIRAI_AGENT_CRED: apiKey,
147
+ PAIRAI_KEY_FILE: keyPath,
148
+ },
149
+ };
150
+
151
+ // Ensure config directory exists
152
+ mkdirSync(dirname(cfg.configPath), { recursive: true });
153
+
154
+ if (cfg.format === "toml") {
155
+ // Codex CLI uses TOML
156
+ let existing = "";
157
+ try { if (existsSync(cfg.configPath)) existing = readFileSync(cfg.configPath, "utf-8"); } catch {}
158
+ const tomlBlock = [
159
+ `\n[mcp_servers.${cfg.mcpKey}]`,
160
+ `command = "npx"`,
161
+ `args = ["pairai", "serve"]`,
162
+ ``,
163
+ `[mcp_servers.${cfg.mcpKey}.env]`,
164
+ `PAIRAI_HUB_URL = "${hubUrl}"`,
165
+ `PAIRAI_AGENT_CRED = "${apiKey}"`,
166
+ `PAIRAI_KEY_FILE = "${keyPath}"`,
167
+ ].join("\n");
168
+ writeFileSync(cfg.configPath, existing + tomlBlock + "\n");
169
+ } else {
170
+ // JSON — merge with existing config
164
171
  let existing: any = {};
165
- try {
166
- if (existsSync(settingsPath)) {
167
- existing = JSON.parse(readFileSync(settingsPath, "utf-8"));
168
- }
169
- } catch {}
170
-
172
+ try { if (existsSync(cfg.configPath)) existing = JSON.parse(readFileSync(cfg.configPath, "utf-8")); } catch {}
171
173
  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
- };
174
+ existing.mcpServers[cfg.mcpKey] = serverEntry;
175
+ writeFileSync(cfg.configPath, JSON.stringify(existing, null, 2) + "\n");
176
+ }
200
177
 
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}`);
178
+ console.log(` Config: ${cfg.configPath}`);
179
+ console.log();
180
+ console.log(` Next steps:`);
181
+ console.log(` 1. ${cfg.instruction}`);
182
+ console.log(` 2. Ask your AI: "Generate a pairing code"`);
183
+ console.log(` 3. Share the code with another agent to connect`);
184
+ if (provider === "claude") {
205
185
  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`);
186
+ console.log(` Optional: Enable real-time notifications (research preview):`);
187
+ console.log(` claude --dangerously-load-development-channels`);
210
188
  }
211
189
 
212
190
  console.log();
@@ -218,8 +196,8 @@ if (command === "setup") {
218
196
  if (command !== "serve") {
219
197
  console.error(`pairai v${VERSION}\n`);
220
198
  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]");
199
+ console.error(' npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
200
+ console.error(" npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]");
223
201
  console.error(" npx pairai upgrade — update to latest version");
224
202
  console.error(" npx pairai version — show current version");
225
203
  process.exit(1);
@@ -238,7 +216,7 @@ if (serveProviderArg) {
238
216
  process.exit(1);
239
217
  }
240
218
  }
241
- const serveProvider = (serveProviderArg as "claude" | "gemini") ?? "claude";
219
+ const serveProvider = (serveProviderArg as Provider) ?? "claude";
242
220
 
243
221
  const HUB_URL = process.env.PAIRAI_HUB_URL ?? process.env.PAIRAI_URL ?? "https://pairai.pro";
244
222
  const API_KEY = process.env.PAIRAI_AGENT_CRED ?? process.env.PAIRAI_API_KEY;
@@ -381,7 +359,7 @@ const capabilities = serveProvider === "claude"
381
359
  : { tools: {} };
382
360
 
383
361
  const mcp = new Server(
384
- { name: "pairai", version: "1.0.0" },
362
+ { name: "pairai", version: VERSION },
385
363
  { capabilities, instructions }
386
364
  );
387
365
 
@@ -389,6 +367,16 @@ const mcp = new Server(
389
367
 
390
368
  mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
391
369
  tools: [
370
+ {
371
+ name: "pairai_check_updates",
372
+ description: "Check for new tasks or unread messages. Call this at the start of every conversation and periodically during active collaborations.",
373
+ inputSchema: {
374
+ type: "object" as const,
375
+ properties: {
376
+ acknowledge: { type: "boolean", description: "If true, marks all current updates as seen after returning them" },
377
+ },
378
+ },
379
+ },
392
380
  {
393
381
  name: "pairai_reply",
394
382
  description: "Send a message to the other agent in a task.",
@@ -463,6 +451,8 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
463
451
  description: { type: "string", description: "What this agent does (max 500 chars)" },
464
452
  capabilities: { type: "array", items: { type: "string" }, description: "List of capabilities, e.g. ['scheduling', 'code-review']" },
465
453
  metadata: { type: "object", description: "Arbitrary JSON metadata (max 4KB)" },
454
+ discoverable: { type: "boolean", description: "Whether to appear in the public agent directory" },
455
+ defaultApprovalRule: { type: "string", enum: ["auto", "require"], description: "Default approval rule for new connections" },
466
456
  },
467
457
  },
468
458
  },
@@ -478,6 +468,70 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
478
468
  required: ["connection_id"],
479
469
  },
480
470
  },
471
+ {
472
+ name: "pairai_update_webhook",
473
+ description: "Configure a webhook URL to receive events. Set url to null to disable.",
474
+ inputSchema: {
475
+ type: "object" as const,
476
+ properties: {
477
+ url: { type: ["string", "null"], description: "HTTPS webhook endpoint, or null to disable" },
478
+ secret: { type: "string", description: "Shared secret for HMAC-SHA256 signature (min 16 chars)" },
479
+ events: { type: "array", items: { type: "string" }, description: "Event types to receive (empty = all)" },
480
+ },
481
+ },
482
+ },
483
+ {
484
+ name: "pairai_discover_agents",
485
+ description: "Search the public directory of discoverable agents by capability or name.",
486
+ inputSchema: {
487
+ type: "object" as const,
488
+ properties: {
489
+ capability: { type: "string", description: "Filter by capability tag" },
490
+ query: { type: "string", description: "Search name and description" },
491
+ limit: { type: "number", description: "Max results (default 20)" },
492
+ },
493
+ },
494
+ },
495
+ {
496
+ name: "pairai_set_approval_rule",
497
+ description: "Set whether incoming tasks from a connection require human approval. 'auto' accepts automatically, 'require' holds tasks pending.",
498
+ inputSchema: {
499
+ type: "object" as const,
500
+ properties: {
501
+ connection_id: { type: "string", description: "Connection ID" },
502
+ rule: { type: "string", enum: ["auto", "require"], description: "Approval rule" },
503
+ },
504
+ required: ["connection_id", "rule"],
505
+ },
506
+ },
507
+ {
508
+ name: "pairai_list_pending_approvals",
509
+ description: "List tasks waiting for your approval.",
510
+ inputSchema: { type: "object" as const, properties: {} },
511
+ },
512
+ {
513
+ name: "pairai_approve_task",
514
+ description: "Approve a task that is pending your approval.",
515
+ inputSchema: {
516
+ type: "object" as const,
517
+ properties: {
518
+ task_id: { type: "string", description: "Task ID" },
519
+ },
520
+ required: ["task_id"],
521
+ },
522
+ },
523
+ {
524
+ name: "pairai_reject_task",
525
+ description: "Reject a task pending your approval. Optionally provide a reason.",
526
+ inputSchema: {
527
+ type: "object" as const,
528
+ properties: {
529
+ task_id: { type: "string", description: "Task ID" },
530
+ reason: { type: "string", description: "Reason for rejection" },
531
+ },
532
+ required: ["task_id"],
533
+ },
534
+ },
481
535
  {
482
536
  name: "pairai_list_tasks",
483
537
  description: "List all tasks you are involved in (as initiator or target).",
@@ -531,7 +585,55 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
531
585
 
532
586
  mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
533
587
  const { name, arguments: a } = req.params;
534
- const args = a as Record<string, string>;
588
+ const args = a as Record<string, unknown>;
589
+
590
+ if (name === "pairai_check_updates") {
591
+ await loadPublicKeys();
592
+ const updates = (await hubGet("/updates")) as {
593
+ hasUpdates: boolean;
594
+ pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
595
+ unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
596
+ };
597
+
598
+ if (!updates.hasUpdates) {
599
+ return { content: [{ type: "text" as const, text: "No updates. You're all caught up." }] };
600
+ }
601
+
602
+ const parts: string[] = [];
603
+
604
+ if (updates.pendingTasks.length > 0) {
605
+ const enriched: string[] = [];
606
+ for (const task of updates.pendingTasks) {
607
+ const full = (await hubGet(`/tasks/${task.id}`)) as any;
608
+ const desc = full.encrypted ? decryptTaskDescription(full, task.id) : (full.description ?? "");
609
+ const title = desc.split("\n")[0] || task.title;
610
+ enriched.push(`- "${title}" from ${task.fromAgent} (task ID: ${task.id})`);
611
+ }
612
+ parts.push(`**${updates.pendingTasks.length} pending task(s):**\n${enriched.join("\n")}`);
613
+ }
614
+
615
+ if (updates.unreadMessages.length > 0) {
616
+ const enriched: string[] = [];
617
+ for (const unread of updates.unreadMessages) {
618
+ const full = (await hubGet(`/tasks/${unread.taskId}`)) as any;
619
+ const msgs = (full.messages ?? []) as Array<any>;
620
+ const recent = msgs.slice(-unread.count);
621
+ const previews: string[] = [];
622
+ for (const m of recent) {
623
+ const d = full.encrypted ? decryptMessage(m, unread.taskId) : { content: m.content, contentType: m.contentType };
624
+ previews.push(d.content.slice(0, 100));
625
+ }
626
+ enriched.push(`- ${unread.count} new in "${unread.taskTitle}" (task ID: ${unread.taskId})\n Preview: ${previews.join(" | ")}`);
627
+ }
628
+ parts.push(`**Unread messages:**\n${enriched.join("\n")}`);
629
+ }
630
+
631
+ if ((args as any).acknowledge) {
632
+ await hubPost("/updates/ack");
633
+ }
634
+
635
+ return { content: [{ type: "text" as const, text: parts.join("\n\n") }] };
636
+ }
535
637
 
536
638
  if (name === "pairai_reply") {
537
639
  const { task_id, text, content_type } = args as { task_id: string; text: string; content_type?: string };
@@ -638,6 +740,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
638
740
  if (args.description !== undefined) body.description = args.description;
639
741
  if (args.capabilities !== undefined) body.capabilities = args.capabilities;
640
742
  if (args.metadata !== undefined) body.metadata = args.metadata;
743
+ if (args.discoverable !== undefined) body.discoverable = args.discoverable === "true" || args.discoverable === true;
744
+ if (args.defaultApprovalRule !== undefined) body.defaultApprovalRule = args.defaultApprovalRule;
641
745
  const data = await hubPatch("/agents/me", body);
642
746
  return { content: [{ type: "text" as const, text: "Profile updated.\n" + JSON.stringify(data, null, 2) }] };
643
747
  }
@@ -648,6 +752,47 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
648
752
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
649
753
  }
650
754
 
755
+ if (name === "pairai_update_webhook") {
756
+ const body: Record<string, unknown> = {};
757
+ if (args.url !== undefined) body.webhookUrl = args.url;
758
+ if (args.secret !== undefined) body.webhookSecret = args.secret;
759
+ if (args.events !== undefined) body.webhookEvents = args.events;
760
+ const data = await hubPatch("/agents/me", body);
761
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
762
+ }
763
+
764
+ if (name === "pairai_discover_agents") {
765
+ const params = new URLSearchParams();
766
+ if (args.capability) params.set("capability", args.capability);
767
+ if (args.query) params.set("q", args.query);
768
+ if (args.limit) params.set("limit", args.limit);
769
+ const qs = params.toString();
770
+ const data = await hubGet(`/agents/discover${qs ? `?${qs}` : ""}`);
771
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
772
+ }
773
+
774
+ if (name === "pairai_set_approval_rule") {
775
+ const { connection_id, rule } = args as { connection_id: string; rule: string };
776
+ const data = await hubPatch(`/connections/${connection_id}`, { approval: rule });
777
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
778
+ }
779
+
780
+ if (name === "pairai_list_pending_approvals") {
781
+ const data = await hubGet("/approvals");
782
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
783
+ }
784
+
785
+ if (name === "pairai_approve_task") {
786
+ const data = await hubPost(`/approvals/${args.task_id}/approve`);
787
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
788
+ }
789
+
790
+ if (name === "pairai_reject_task") {
791
+ const { task_id, reason } = args as { task_id: string; reason?: string };
792
+ const data = await hubPost(`/approvals/${task_id}/reject`, reason ? { reason } : undefined);
793
+ return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
794
+ }
795
+
651
796
  if (name === "pairai_list_tasks") {
652
797
  await loadPublicKeys();
653
798
  const data = (await hubGet("/tasks")) as Array<{
@@ -813,6 +958,7 @@ async function poll() {
813
958
  descriptionKeys?: any;
814
959
  senderSignature?: string;
815
960
  initiatorAgentId?: string;
961
+ approvalStatus?: string | null;
816
962
  messages: Array<{
817
963
  content: string;
818
964
  contentType: string;
@@ -828,15 +974,26 @@ async function poll() {
828
974
  return d.content;
829
975
  });
830
976
 
831
- const body = [desc || task.title, ...decryptedMessages.map((c) => `> ${c}`)].join("\n\n");
977
+ const isPendingApproval = full.approvalStatus === "pending";
978
+ const approvalPrefix = isPendingApproval ? "[APPROVAL REQUIRED] " : "";
979
+ const approvalSuffix = isPendingApproval
980
+ ? `\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}`
981
+ : "";
832
982
 
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
- });
983
+ const body = approvalPrefix + [desc || task.title, ...decryptedMessages.map((c) => `> ${c}`)].join("\n\n") + approvalSuffix;
984
+
985
+ try {
986
+ await mcp.notification({
987
+ method: "notifications/claude/channel",
988
+ params: {
989
+ content: body,
990
+ meta: { task_id: task.id, task_title: task.title, from_agent: task.fromAgent, event_type: "new_task" },
991
+ },
992
+ });
993
+ console.error(`[pairai] channel notification sent: new_task ${task.id} from ${task.fromAgent}${isPendingApproval ? " [APPROVAL REQUIRED]" : ""}`);
994
+ } catch (err) {
995
+ console.error(`[pairai] channel notification FAILED: ${(err as Error).message}`);
996
+ }
840
997
  }
841
998
 
842
999
  for (const unread of updates.unreadMessages) {
@@ -859,19 +1016,24 @@ async function poll() {
859
1016
 
860
1017
  const decrypted = decryptMessage(msg, unread.taskId);
861
1018
 
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,
1019
+ try {
1020
+ await mcp.notification({
1021
+ method: "notifications/claude/channel",
1022
+ params: {
1023
+ content: decrypted.content,
1024
+ meta: {
1025
+ task_id: unread.taskId,
1026
+ task_title: unread.taskTitle,
1027
+ from_agent: msg.senderAgentId,
1028
+ event_type: "new_message",
1029
+ content_type: decrypted.contentType,
1030
+ },
872
1031
  },
873
- },
874
- });
1032
+ });
1033
+ console.error(`[pairai] channel notification sent: new_message in ${unread.taskId}`);
1034
+ } catch (err) {
1035
+ console.error(`[pairai] channel notification FAILED: ${(err as Error).message}`);
1036
+ }
875
1037
  }
876
1038
  }
877
1039
 
@@ -884,7 +1046,26 @@ async function poll() {
884
1046
  // ── Start ────────────────────────────────────────────────────────────────────
885
1047
 
886
1048
  await mcp.connect(new StdioServerTransport());
1049
+ console.error(`[pairai] connected. provider=${serveProvider} channel=${!!capabilities.experimental?.["claude/channel"]} agent=${myAgentId || "(loading)"}`);
887
1050
  await loadAgentInfo();
1051
+ if (!myAgentId) {
1052
+ console.error("[pairai] failed to load agent info from hub. Cannot start polling.");
1053
+ process.exit(1);
1054
+ }
888
1055
  await loadPublicKeys();
1056
+
1057
+ // Acquire polling lock — only one channel process per agent
1058
+ const lockDir = process.env.PAIRAI_LOCK_DIR || undefined;
1059
+ if (!acquireLock(myAgentId, lockDir)) {
1060
+ console.error(`[pairai] another instance is already polling for agent ${myAgentId}. Exiting.`);
1061
+ process.exit(0);
1062
+ }
1063
+ const cleanupLock = () => { try { releaseLock(myAgentId, lockDir); } catch {} };
1064
+ process.on("SIGTERM", cleanupLock);
1065
+ process.on("SIGINT", cleanupLock);
1066
+ process.on("beforeExit", cleanupLock);
1067
+ process.on("exit", cleanupLock);
1068
+
1069
+ console.error(`[pairai] agent=${myAgentId} keys=${pubKeyCache.size} polling every ${POLL_MS}ms`);
889
1070
  setInterval(poll, POLL_MS);
890
1071
  poll();