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.
- package/lib.ts +183 -14
- package/package.json +1 -1
- 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):
|
|
13
|
-
if (value
|
|
14
|
-
throw new Error(`Unknown provider "${value}". Must be
|
|
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():
|
|
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:
|
|
142
|
+
provider: Provider,
|
|
41
143
|
cwd: string,
|
|
42
144
|
homeDir: string,
|
|
43
145
|
useGlobal: boolean,
|
|
44
146
|
): string | null {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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.
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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(`
|
|
207
|
-
console.log(`
|
|
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
|
|
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:
|
|
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,
|
|
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
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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();
|