pairai 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib.ts +31 -23
- package/package.json +2 -1
- package/pairai.ts +311 -164
package/lib.ts
CHANGED
|
@@ -43,14 +43,14 @@ export function detectProvider(): Provider | null {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export interface ProviderConfig {
|
|
46
|
-
/** Config file path (project-level or
|
|
46
|
+
/** Config file path (project-level or user-scoped) */
|
|
47
47
|
configPath: string;
|
|
48
48
|
/** MCP server key name in the config */
|
|
49
49
|
mcpKey: string;
|
|
50
50
|
/** Format: "json" or "toml" */
|
|
51
51
|
format: "json" | "toml";
|
|
52
|
-
/** Whether this provider only supports
|
|
53
|
-
|
|
52
|
+
/** Whether this provider only supports user-scoped config */
|
|
53
|
+
userOnly: boolean;
|
|
54
54
|
/** Post-setup instruction */
|
|
55
55
|
instruction: string;
|
|
56
56
|
}
|
|
@@ -62,34 +62,42 @@ export function getProviderConfig(
|
|
|
62
62
|
provider: Provider,
|
|
63
63
|
cwd: string,
|
|
64
64
|
homeDir: string,
|
|
65
|
-
|
|
65
|
+
useUser: boolean,
|
|
66
66
|
): ProviderConfig {
|
|
67
67
|
switch (provider) {
|
|
68
68
|
case "claude":
|
|
69
|
-
return
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
69
|
+
return useUser
|
|
70
|
+
? {
|
|
71
|
+
configPath: join(homeDir, ".claude", "settings.json"),
|
|
72
|
+
mcpKey: "pairai-channel",
|
|
73
|
+
format: "json",
|
|
74
|
+
userOnly: false,
|
|
75
|
+
instruction: "Restart Claude Code to activate the pairai MCP server",
|
|
76
|
+
}
|
|
77
|
+
: {
|
|
78
|
+
configPath: join(cwd, ".mcp.json"),
|
|
79
|
+
mcpKey: "pairai-channel",
|
|
80
|
+
format: "json",
|
|
81
|
+
userOnly: false,
|
|
82
|
+
instruction: "Start Claude Code in this directory",
|
|
83
|
+
};
|
|
76
84
|
case "gemini": {
|
|
77
|
-
const dir =
|
|
85
|
+
const dir = useUser ? join(homeDir, ".gemini") : join(cwd, ".gemini");
|
|
78
86
|
return {
|
|
79
87
|
configPath: join(dir, "settings.json"),
|
|
80
88
|
mcpKey: "pairai",
|
|
81
89
|
format: "json",
|
|
82
|
-
|
|
90
|
+
userOnly: false,
|
|
83
91
|
instruction: "Restart Gemini CLI to activate the pairai MCP server",
|
|
84
92
|
};
|
|
85
93
|
}
|
|
86
94
|
case "cursor": {
|
|
87
|
-
const dir =
|
|
95
|
+
const dir = useUser ? join(homeDir, ".cursor") : join(cwd, ".cursor");
|
|
88
96
|
return {
|
|
89
97
|
configPath: join(dir, "mcp.json"),
|
|
90
98
|
mcpKey: "pairai",
|
|
91
99
|
format: "json",
|
|
92
|
-
|
|
100
|
+
userOnly: false,
|
|
93
101
|
instruction: "Restart Cursor to activate the pairai MCP server",
|
|
94
102
|
};
|
|
95
103
|
}
|
|
@@ -98,7 +106,7 @@ export function getProviderConfig(
|
|
|
98
106
|
configPath: join(cwd, ".vscode", "mcp.json"),
|
|
99
107
|
mcpKey: "pairai",
|
|
100
108
|
format: "json",
|
|
101
|
-
|
|
109
|
+
userOnly: false,
|
|
102
110
|
instruction: "Reload VS Code window (Ctrl+Shift+P → Developer: Reload Window)",
|
|
103
111
|
};
|
|
104
112
|
case "windsurf":
|
|
@@ -106,28 +114,28 @@ export function getProviderConfig(
|
|
|
106
114
|
configPath: join(homeDir, ".codeium", "windsurf", "mcp_config.json"),
|
|
107
115
|
mcpKey: "pairai",
|
|
108
116
|
format: "json",
|
|
109
|
-
|
|
117
|
+
userOnly: true,
|
|
110
118
|
instruction: "Restart Windsurf to activate the pairai MCP server",
|
|
111
119
|
};
|
|
112
120
|
case "codex": {
|
|
113
|
-
const dir =
|
|
121
|
+
const dir = useUser ? join(homeDir, ".codex") : join(cwd, ".codex");
|
|
114
122
|
return {
|
|
115
123
|
configPath: join(dir, "config.toml"),
|
|
116
124
|
mcpKey: "pairai",
|
|
117
125
|
format: "toml",
|
|
118
|
-
|
|
126
|
+
userOnly: false,
|
|
119
127
|
instruction: "Restart Codex CLI to activate the pairai MCP server",
|
|
120
128
|
};
|
|
121
129
|
}
|
|
122
130
|
case "amazonq": {
|
|
123
|
-
const path =
|
|
131
|
+
const path = useUser
|
|
124
132
|
? join(homeDir, ".aws", "amazonq", "default.json")
|
|
125
133
|
: join(cwd, ".amazonq", "default.json");
|
|
126
134
|
return {
|
|
127
135
|
configPath: path,
|
|
128
136
|
mcpKey: "pairai",
|
|
129
137
|
format: "json",
|
|
130
|
-
|
|
138
|
+
userOnly: false,
|
|
131
139
|
instruction: "Restart Amazon Q to activate the pairai MCP server",
|
|
132
140
|
};
|
|
133
141
|
}
|
|
@@ -149,9 +157,9 @@ export function checkExistingConfig(
|
|
|
149
157
|
provider: Provider,
|
|
150
158
|
cwd: string,
|
|
151
159
|
homeDir: string,
|
|
152
|
-
|
|
160
|
+
useUser: boolean,
|
|
153
161
|
): string | null {
|
|
154
|
-
const cfg = getProviderConfig(provider, cwd, homeDir,
|
|
162
|
+
const cfg = getProviderConfig(provider, cwd, homeDir, useUser);
|
|
155
163
|
if (!existsSync(cfg.configPath)) return null;
|
|
156
164
|
|
|
157
165
|
if (cfg.format === "toml") {
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pairai",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "pairai CLI — connect AI agents to collaborate via the pairai hub",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"mcpName": "io.github.pairaipro/pairai",
|
|
7
8
|
"homepage": "https://pairai.pro",
|
|
8
9
|
"repository": {
|
|
9
10
|
"type": "git",
|
package/pairai.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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|cursor|copilot|windsurf|codex|amazonq] [--
|
|
6
|
+
* npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--user | --project] [--force]
|
|
7
7
|
* npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]
|
|
8
8
|
* npx pairai uninstall [--provider ...] [--delete-agent] — remove MCP config, save credentials to ~/.pairai/agents/
|
|
9
9
|
* npx pairai upgrade — update to latest version (preserves keys and config)
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* PAIRAI_KEY_FILE — path to RSA private key .pem
|
|
15
15
|
* PAIRAI_POLL_MS — poll interval in ms (default: 5000)
|
|
16
16
|
* PAIRAI_LOCK_DIR — lock file directory (default: ~/.pairai/locks)
|
|
17
|
+
* PAIRAI_CHANNEL_NOTIFICATIONS — "1" = poll loop acks server cursor (for Claude with --channel)
|
|
17
18
|
* PAIRAI_DEBUG — verbose log: "1" for ~/.pairai/debug.log, or a file path
|
|
18
19
|
* Legacy: PAIRAI_URL, PAIRAI_API_KEY, PAIRAI_PRIVATE_KEY_PATH
|
|
19
20
|
*/
|
|
@@ -68,20 +69,40 @@ if (command === "version" || args.includes("--version") || args.includes("-v"))
|
|
|
68
69
|
// ── Help ────────────────────────────────────────────────────────────────────
|
|
69
70
|
|
|
70
71
|
if (command === "help" || args.includes("--help") || args.includes("-h")) {
|
|
71
|
-
console.log(`pairai v${VERSION}\n`);
|
|
72
|
+
console.log(`pairai v${VERSION} — connect AI agents to collaborate via the pairai hub\n`);
|
|
72
73
|
console.log("Commands:");
|
|
73
|
-
console.log(' setup "Agent Name" [
|
|
74
|
-
console.log(" serve [--provider ...] — start the MCP channel server");
|
|
74
|
+
console.log(' setup "Agent Name" [options] — register agent and configure MCP server');
|
|
75
|
+
console.log(" serve [--provider ...] — start the MCP channel server (stdio)");
|
|
75
76
|
console.log(" uninstall [--provider ...] [--delete-agent]");
|
|
77
|
+
console.log(" — remove MCP config, preserve credentials");
|
|
76
78
|
console.log(" upgrade — update to latest version");
|
|
77
79
|
console.log(" version — show version");
|
|
78
|
-
console.log("\
|
|
79
|
-
console.log("
|
|
80
|
+
console.log("\nSetup options:");
|
|
81
|
+
console.log(" --hub URL Hub URL (default: https://pairai.pro)");
|
|
82
|
+
console.log(" --provider NAME AI tool to configure (see list below)");
|
|
83
|
+
console.log(" --project Write MCP config to current project directory (default)");
|
|
84
|
+
console.log(" --user Write MCP config to user home directory (~/)");
|
|
85
|
+
console.log(" Makes pairai available in all projects without per-project setup");
|
|
86
|
+
console.log(" --force Overwrite existing config without prompting");
|
|
87
|
+
console.log("\nProviders:");
|
|
88
|
+
console.log(" claude Claude Code / Claude Desktop (.mcp.json or ~/.claude/settings.json)");
|
|
89
|
+
console.log(" gemini Gemini CLI (.gemini/ or ~/.gemini/settings.json)");
|
|
90
|
+
console.log(" cursor Cursor IDE (.cursor/ or ~/.cursor/mcp.json)");
|
|
91
|
+
console.log(" copilot GitHub Copilot (VS Code) (.vscode/mcp.json)");
|
|
92
|
+
console.log(" windsurf Windsurf IDE (~/.codeium/windsurf/ — user only)");
|
|
93
|
+
console.log(" codex Codex CLI (.codex/ or ~/.codex/config.toml)");
|
|
94
|
+
console.log(" amazonq Amazon Q Developer (.amazonq/ or ~/.aws/amazonq/)");
|
|
95
|
+
console.log("\nEnvironment variables (for serve command):");
|
|
80
96
|
console.log(" PAIRAI_HUB_URL Hub URL (default: https://pairai.pro)");
|
|
81
97
|
console.log(" PAIRAI_AGENT_CRED Agent API key");
|
|
82
98
|
console.log(" PAIRAI_KEY_FILE Path to RSA private key .pem");
|
|
83
99
|
console.log(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
|
|
100
|
+
console.log(" PAIRAI_CHANNEL_NOTIFICATIONS=1 Poll acks cursor (Claude --channel)");
|
|
84
101
|
console.log(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
|
|
102
|
+
console.log("\nExamples:");
|
|
103
|
+
console.log(' npx pairai setup "My Assistant"');
|
|
104
|
+
console.log(' npx pairai setup "My Assistant" --provider claude --user');
|
|
105
|
+
console.log(" npx pairai uninstall --provider cursor --delete-agent");
|
|
85
106
|
process.exit(0);
|
|
86
107
|
}
|
|
87
108
|
|
|
@@ -153,18 +174,18 @@ if (command === "uninstall") {
|
|
|
153
174
|
let removed = 0;
|
|
154
175
|
let savedCredentials = false;
|
|
155
176
|
|
|
156
|
-
// Collect both project-level and user
|
|
177
|
+
// Collect both project-level and user-scoped config paths
|
|
157
178
|
const scopes: Array<{ label: string; cfg: ReturnType<typeof getProviderConfig> }> = [];
|
|
158
179
|
scopes.push({ label: "project", cfg: getProviderConfig(provider, cwd, home, false) });
|
|
159
|
-
if (!getProviderConfig(provider, cwd, home, false).
|
|
180
|
+
if (!getProviderConfig(provider, cwd, home, false).userOnly) {
|
|
160
181
|
scopes.push({ label: "user", cfg: getProviderConfig(provider, cwd, home, true) });
|
|
161
182
|
}
|
|
162
|
-
// For claude, also check ~/.mcp.json (user-scope
|
|
183
|
+
// For claude, also check legacy ~/.mcp.json (user-scope config from older versions)
|
|
163
184
|
if (provider === "claude") {
|
|
164
185
|
const userMcpJson = join(home, ".mcp.json");
|
|
165
186
|
scopes.push({
|
|
166
187
|
label: "user (~/.mcp.json)",
|
|
167
|
-
cfg: { configPath: userMcpJson, mcpKey: "pairai-channel", format: "json" as const,
|
|
188
|
+
cfg: { configPath: userMcpJson, mcpKey: "pairai-channel", format: "json" as const, userOnly: true, instruction: "" },
|
|
168
189
|
});
|
|
169
190
|
}
|
|
170
191
|
|
|
@@ -354,8 +375,12 @@ if (command === "setup") {
|
|
|
354
375
|
process.exit(1);
|
|
355
376
|
}
|
|
356
377
|
}
|
|
357
|
-
|
|
358
|
-
|
|
378
|
+
// --user installs to user home directory; --project (default) installs to current project
|
|
379
|
+
// --global is accepted as a backward-compatible alias for --user
|
|
380
|
+
const userIdx = Math.max(rest.indexOf("--user"), rest.indexOf("--global"));
|
|
381
|
+
const useUser = userIdx !== -1 ? (rest.splice(userIdx, 1), true) : false;
|
|
382
|
+
const projectIdx = rest.indexOf("--project");
|
|
383
|
+
if (projectIdx !== -1) rest.splice(projectIdx, 1); // explicit default, just consume it
|
|
359
384
|
let agentName = rest.find((a) => !a.startsWith("--"));
|
|
360
385
|
|
|
361
386
|
const forceIdx = rest.indexOf("--force");
|
|
@@ -367,14 +392,14 @@ if (command === "setup") {
|
|
|
367
392
|
validate: (v) => v.trim().length > 0 && v.trim().length <= 64 ? true : "Name must be 1-64 characters",
|
|
368
393
|
});
|
|
369
394
|
} else {
|
|
370
|
-
console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--
|
|
395
|
+
console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--user | --project] [--force]');
|
|
371
396
|
process.exit(1);
|
|
372
397
|
}
|
|
373
398
|
}
|
|
374
399
|
|
|
375
400
|
// Check for existing config to avoid accidental overwrites
|
|
376
401
|
if (!useForce) {
|
|
377
|
-
const existingConfigPath = checkExistingConfig(provider, process.cwd(), homedir(),
|
|
402
|
+
const existingConfigPath = checkExistingConfig(provider, process.cwd(), homedir(), useUser);
|
|
378
403
|
if (existingConfigPath) {
|
|
379
404
|
console.error(`\n pairai is already configured in ${existingConfigPath}`);
|
|
380
405
|
console.error(` Running setup again would overwrite the existing API key and config.`);
|
|
@@ -417,7 +442,7 @@ if (command === "setup") {
|
|
|
417
442
|
for (const line of formatKeyBackupBox(keyPath)) console.log(line);
|
|
418
443
|
console.log();
|
|
419
444
|
|
|
420
|
-
const cfg = getProviderConfig(provider, process.cwd(), homedir(),
|
|
445
|
+
const cfg = getProviderConfig(provider, process.cwd(), homedir(), useUser);
|
|
421
446
|
const serverEntry = {
|
|
422
447
|
command: "npx",
|
|
423
448
|
args: ["pairai", "serve"],
|
|
@@ -463,8 +488,12 @@ if (command === "setup") {
|
|
|
463
488
|
console.log(` 3. Share the code with another agent to connect`);
|
|
464
489
|
if (provider === "claude") {
|
|
465
490
|
console.log();
|
|
466
|
-
console.log(`
|
|
467
|
-
console.log(` claude
|
|
491
|
+
console.log(` Tips for Claude Code:`);
|
|
492
|
+
console.log(` Auto-allow all pairai tools — add to .claude/settings.local.json:`);
|
|
493
|
+
console.log(` { "permissions": { "allow": ["mcp__${cfg.mcpKey}__*"] } }`);
|
|
494
|
+
console.log();
|
|
495
|
+
console.log(` Enable real-time notifications (research preview):`);
|
|
496
|
+
console.log(` claude --dangerously-load-development-channels server:${cfg.mcpKey}`);
|
|
468
497
|
}
|
|
469
498
|
|
|
470
499
|
console.log();
|
|
@@ -476,7 +505,7 @@ if (command === "setup") {
|
|
|
476
505
|
if (command !== "serve") {
|
|
477
506
|
console.error(`pairai v${VERSION}\n`);
|
|
478
507
|
console.error("Usage:");
|
|
479
|
-
console.error(' npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--
|
|
508
|
+
console.error(' npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--user | --project] [--force]');
|
|
480
509
|
console.error(" npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]");
|
|
481
510
|
console.error(" npx pairai uninstall [--provider ...] [--delete-agent] — remove MCP config, preserve keys");
|
|
482
511
|
console.error(" npx pairai upgrade — update to latest version");
|
|
@@ -488,6 +517,7 @@ if (command !== "serve") {
|
|
|
488
517
|
console.error(" PAIRAI_KEY_FILE Path to RSA private key .pem file");
|
|
489
518
|
console.error(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
|
|
490
519
|
console.error(" PAIRAI_LOCK_DIR Lock file directory (default: ~/.pairai/locks)");
|
|
520
|
+
console.error(" PAIRAI_CHANNEL_NOTIFICATIONS=1 Poll acks cursor (Claude --channel)");
|
|
491
521
|
console.error(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
|
|
492
522
|
console.error(" PAIRAI_DEBUG=<path> Verbose log to custom file");
|
|
493
523
|
process.exit(1);
|
|
@@ -1039,49 +1069,76 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1039
1069
|
|
|
1040
1070
|
if (name === "pairai_check_updates") {
|
|
1041
1071
|
await loadPublicKeys();
|
|
1042
|
-
const updates = (await hubGet("/
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1072
|
+
const updates = (await hubGet("/events")) as {
|
|
1073
|
+
events: Array<{
|
|
1074
|
+
id: number;
|
|
1075
|
+
type: string;
|
|
1076
|
+
taskId: string | null;
|
|
1077
|
+
fromAgentId: string | null;
|
|
1078
|
+
data: Record<string, unknown>;
|
|
1079
|
+
createdAt: string;
|
|
1080
|
+
}>;
|
|
1046
1081
|
cursor: number;
|
|
1082
|
+
hasMore: boolean;
|
|
1047
1083
|
};
|
|
1048
1084
|
|
|
1049
|
-
if (
|
|
1085
|
+
if (updates.events.length === 0) {
|
|
1050
1086
|
return { content: [{ type: "text" as const, text: "No updates. You're all caught up." }] };
|
|
1051
1087
|
}
|
|
1052
1088
|
|
|
1053
1089
|
const parts: string[] = [];
|
|
1054
1090
|
|
|
1055
|
-
|
|
1091
|
+
const taskEvents = updates.events.filter(e => e.type === "task.created" || e.type === "task.approval_required");
|
|
1092
|
+
if (taskEvents.length > 0) {
|
|
1056
1093
|
const enriched: string[] = [];
|
|
1057
|
-
for (const
|
|
1058
|
-
|
|
1059
|
-
const
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1094
|
+
for (const event of taskEvents) {
|
|
1095
|
+
if (!event.taskId) continue;
|
|
1096
|
+
const full = (await hubGet(`/tasks/${event.taskId}`)) as any;
|
|
1097
|
+
const desc = full.encrypted ? decryptTaskDescription(full, event.taskId) : (full.description ?? "");
|
|
1098
|
+
const title = desc.split("\n")[0] || full.title || "Untitled";
|
|
1099
|
+
const fromAgent = (event.data.fromAgentName as string) ?? event.fromAgentId ?? "unknown";
|
|
1100
|
+
enriched.push(`- "${title}" from ${fromAgent} (task ID: ${event.taskId})${event.type === "task.approval_required" ? " [APPROVAL REQUIRED]" : ""}`);
|
|
1062
1101
|
}
|
|
1063
|
-
parts.push(`**${
|
|
1102
|
+
parts.push(`**${taskEvents.length} pending task(s):**\n${enriched.join("\n")}`);
|
|
1064
1103
|
}
|
|
1065
1104
|
|
|
1066
|
-
|
|
1105
|
+
const msgEvents = updates.events.filter(e => e.type === "message.created");
|
|
1106
|
+
if (msgEvents.length > 0) {
|
|
1107
|
+
// Group by taskId for summary
|
|
1108
|
+
const byTask = new Map<string, typeof msgEvents>();
|
|
1109
|
+
for (const event of msgEvents) {
|
|
1110
|
+
if (!event.taskId) continue;
|
|
1111
|
+
const list = byTask.get(event.taskId) ?? [];
|
|
1112
|
+
list.push(event);
|
|
1113
|
+
byTask.set(event.taskId, list);
|
|
1114
|
+
}
|
|
1067
1115
|
const enriched: string[] = [];
|
|
1068
|
-
for (const
|
|
1069
|
-
const full = (await hubGet(`/tasks/${
|
|
1070
|
-
const
|
|
1071
|
-
const
|
|
1116
|
+
for (const [taskId, events] of byTask) {
|
|
1117
|
+
const full = (await hubGet(`/tasks/${taskId}`)) as any;
|
|
1118
|
+
const taskTitle = full.title ?? "Untitled";
|
|
1119
|
+
const msgs = (await hubGet(`/tasks/${taskId}/messages`)) as Array<any>;
|
|
1072
1120
|
const previews: string[] = [];
|
|
1073
|
-
for (const
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1121
|
+
for (const event of events) {
|
|
1122
|
+
const messageId = event.data.messageId as string | undefined;
|
|
1123
|
+
const msg = messageId ? msgs.find((m: any) => m.id === messageId) : msgs[msgs.length - 1];
|
|
1124
|
+
if (msg) {
|
|
1125
|
+
const d = full.encrypted ? decryptMessage(msg, taskId) : { content: msg.content, contentType: msg.contentType };
|
|
1126
|
+
previews.push(d.content.slice(0, 100));
|
|
1127
|
+
}
|
|
1076
1128
|
}
|
|
1077
|
-
enriched.push(`- ${
|
|
1129
|
+
enriched.push(`- ${events.length} new in "${taskTitle}" (task ID: ${taskId})\n Preview: ${previews.join(" | ")}`);
|
|
1078
1130
|
}
|
|
1079
1131
|
parts.push(`**Unread messages:**\n${enriched.join("\n")}`);
|
|
1080
1132
|
}
|
|
1081
1133
|
|
|
1082
|
-
// Ack
|
|
1134
|
+
// Ack server-side cursor. For channel-capable clients the poll loop also acks,
|
|
1135
|
+
// but for non-channel clients this is the only place the server cursor advances.
|
|
1083
1136
|
if (updates.cursor > 0) {
|
|
1084
|
-
await hubPost("/
|
|
1137
|
+
await hubPost("/events/ack", { cursor: updates.cursor });
|
|
1138
|
+
// Sync local poll cursor so we don't re-notify for these events
|
|
1139
|
+
if (!channelCapable && updates.cursor > lastNotifiedEventId) {
|
|
1140
|
+
lastNotifiedEventId = updates.cursor;
|
|
1141
|
+
}
|
|
1085
1142
|
}
|
|
1086
1143
|
|
|
1087
1144
|
return { content: [{ type: "text" as const, text: parts.join("\n\n") }] };
|
|
@@ -1671,9 +1728,6 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1671
1728
|
|
|
1672
1729
|
// ── Polling ──────────────────────────────────────────────────────────────────
|
|
1673
1730
|
|
|
1674
|
-
const seenMessages = new Set<string>();
|
|
1675
|
-
const SEEN_MESSAGES_MAX = 10_000;
|
|
1676
|
-
|
|
1677
1731
|
const MIME_MAP: Record<string, string> = {
|
|
1678
1732
|
".md": "text/markdown", ".txt": "text/plain", ".json": "application/json",
|
|
1679
1733
|
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
@@ -1735,147 +1789,238 @@ function decryptTaskDescription(
|
|
|
1735
1789
|
return full.description ?? "";
|
|
1736
1790
|
}
|
|
1737
1791
|
|
|
1738
|
-
async function
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1792
|
+
async function deliverEventNotification(event: {
|
|
1793
|
+
id: number;
|
|
1794
|
+
type: string;
|
|
1795
|
+
taskId: string | null;
|
|
1796
|
+
fromAgentId: string | null;
|
|
1797
|
+
data: Record<string, unknown>;
|
|
1798
|
+
createdAt: string;
|
|
1799
|
+
}) {
|
|
1800
|
+
const fromAgent = (event.data.fromAgentName as string) ?? event.fromAgentId ?? "unknown";
|
|
1801
|
+
|
|
1802
|
+
if (event.type === "task.created" || event.type === "task.approval_required") {
|
|
1803
|
+
if (!event.taskId) return;
|
|
1804
|
+
|
|
1805
|
+
const full = (await hubGet(`/tasks/${event.taskId}`)) as {
|
|
1806
|
+
title?: string;
|
|
1807
|
+
description?: string;
|
|
1808
|
+
encrypted?: boolean;
|
|
1809
|
+
descriptionKeys?: any;
|
|
1810
|
+
senderSignature?: string;
|
|
1811
|
+
initiatorAgentId?: string;
|
|
1812
|
+
approvalStatus?: string | null;
|
|
1748
1813
|
};
|
|
1814
|
+
const taskMsgs = (await hubGet(`/tasks/${event.taskId}/messages`)) as Array<{
|
|
1815
|
+
content: string;
|
|
1816
|
+
contentType: string;
|
|
1817
|
+
senderAgentId: string;
|
|
1818
|
+
encryptedKeys?: any;
|
|
1819
|
+
senderSignature?: string;
|
|
1820
|
+
}>;
|
|
1749
1821
|
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
initiatorAgentId?: string;
|
|
1765
|
-
approvalStatus?: string | null;
|
|
1766
|
-
};
|
|
1767
|
-
const taskMsgs = (await hubGet(`/tasks/${task.id}/messages`)) as Array<{
|
|
1768
|
-
content: string;
|
|
1769
|
-
contentType: string;
|
|
1770
|
-
senderAgentId: string;
|
|
1771
|
-
encryptedKeys?: any;
|
|
1772
|
-
senderSignature?: string;
|
|
1773
|
-
}>;
|
|
1822
|
+
const desc = decryptTaskDescription(full, event.taskId);
|
|
1823
|
+
const taskTitle = desc.split("\n")[0] || full.title || "Untitled";
|
|
1824
|
+
const decryptedMessages = (taskMsgs ?? []).map((m) => {
|
|
1825
|
+
// Encrypted file messages: content is a file ID (short nanoid), not ciphertext
|
|
1826
|
+
if (m.contentType === "encrypted" && m.encryptedKeys && m.content.length < 30) {
|
|
1827
|
+
return `[File attachment — use pairai_download_file with task_id: "${event.taskId}", file_id: "${m.content}"]`;
|
|
1828
|
+
}
|
|
1829
|
+
try {
|
|
1830
|
+
const d = decryptMessage(m, event.taskId!);
|
|
1831
|
+
return d.content;
|
|
1832
|
+
} catch {
|
|
1833
|
+
return "[decryption failed]";
|
|
1834
|
+
}
|
|
1835
|
+
});
|
|
1774
1836
|
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
const d = decryptMessage(m, task.id);
|
|
1783
|
-
return d.content;
|
|
1784
|
-
} catch {
|
|
1785
|
-
return "[decryption failed]";
|
|
1786
|
-
}
|
|
1787
|
-
});
|
|
1837
|
+
const isPendingApproval = full.approvalStatus === "pending" || event.type === "task.approval_required";
|
|
1838
|
+
const approvalPrefix = isPendingApproval ? "[APPROVAL REQUIRED] " : "";
|
|
1839
|
+
const approvalSuffix = isPendingApproval
|
|
1840
|
+
? `\n\nThis task requires your approval before the agent will act on it.\nUse pairai_approve_task or pairai_reject_task with task ID: ${event.taskId}`
|
|
1841
|
+
: "";
|
|
1842
|
+
|
|
1843
|
+
const body = approvalPrefix + [desc || taskTitle, ...decryptedMessages.map((c) => `> ${c}`)].join("\n\n") + approvalSuffix;
|
|
1788
1844
|
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
: ""
|
|
1845
|
+
await mcp.notification({
|
|
1846
|
+
method: "notifications/claude/channel",
|
|
1847
|
+
params: {
|
|
1848
|
+
content: body,
|
|
1849
|
+
meta: { task_id: event.taskId, task_title: taskTitle, from_agent: fromAgent, event_type: "new_task" },
|
|
1850
|
+
},
|
|
1851
|
+
});
|
|
1852
|
+
console.error(`[pairai] channel notification sent: new_task ${event.taskId} from ${fromAgent}${isPendingApproval ? " [APPROVAL REQUIRED]" : ""}`);
|
|
1853
|
+
|
|
1854
|
+
} else if (event.type === "message.created") {
|
|
1855
|
+
if (!event.taskId) return;
|
|
1856
|
+
|
|
1857
|
+
const msgs = (await hubGet(`/tasks/${event.taskId}/messages`)) as Array<{
|
|
1858
|
+
id: string;
|
|
1859
|
+
content: string;
|
|
1860
|
+
contentType: string;
|
|
1861
|
+
senderAgentId: string;
|
|
1862
|
+
encryptedKeys?: any;
|
|
1863
|
+
senderSignature?: string;
|
|
1864
|
+
}>;
|
|
1865
|
+
if (!msgs || msgs.length === 0) return;
|
|
1794
1866
|
|
|
1795
|
-
|
|
1867
|
+
const messageId = event.data.messageId as string | undefined;
|
|
1868
|
+
const msg = messageId ? msgs.find((m) => m.id === messageId) : msgs[msgs.length - 1];
|
|
1869
|
+
if (!msg) return;
|
|
1796
1870
|
|
|
1871
|
+
// Encrypted file messages: content is a file ID (short nanoid), not ciphertext
|
|
1872
|
+
const isEncryptedFile = msg.contentType === "encrypted" && msg.encryptedKeys && msg.content.length < 30;
|
|
1873
|
+
let decrypted: { content: string; contentType: string };
|
|
1874
|
+
if (isEncryptedFile) {
|
|
1875
|
+
decrypted = { content: `[File attachment — use pairai_download_file with task_id: "${event.taskId}", file_id: "${msg.content}"]`, contentType: "text" };
|
|
1876
|
+
} else {
|
|
1797
1877
|
try {
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
content: body,
|
|
1802
|
-
meta: { task_id: task.id, task_title: task.title, from_agent: task.fromAgent, event_type: "new_task" },
|
|
1803
|
-
},
|
|
1804
|
-
});
|
|
1805
|
-
console.error(`[pairai] channel notification sent: new_task ${task.id} from ${task.fromAgent}${isPendingApproval ? " [APPROVAL REQUIRED]" : ""}`);
|
|
1806
|
-
} catch (err) {
|
|
1807
|
-
console.error(`[pairai] channel notification FAILED: ${(err as Error).message}`);
|
|
1878
|
+
decrypted = decryptMessage(msg, event.taskId);
|
|
1879
|
+
} catch {
|
|
1880
|
+
decrypted = { content: "[decryption failed]", contentType: "text" };
|
|
1808
1881
|
}
|
|
1809
1882
|
}
|
|
1810
1883
|
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1884
|
+
const full = (await hubGet(`/tasks/${event.taskId}`)) as { title?: string };
|
|
1885
|
+
const taskTitle = full.title ?? "Untitled";
|
|
1886
|
+
|
|
1887
|
+
await mcp.notification({
|
|
1888
|
+
method: "notifications/claude/channel",
|
|
1889
|
+
params: {
|
|
1890
|
+
content: decrypted.content,
|
|
1891
|
+
meta: {
|
|
1892
|
+
task_id: event.taskId,
|
|
1893
|
+
task_title: taskTitle,
|
|
1894
|
+
from_agent: fromAgent,
|
|
1895
|
+
event_type: "new_message",
|
|
1896
|
+
content_type: decrypted.contentType,
|
|
1897
|
+
},
|
|
1898
|
+
},
|
|
1899
|
+
});
|
|
1900
|
+
console.error(`[pairai] channel notification sent: new_message in ${event.taskId}`);
|
|
1820
1901
|
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
if (seenMessages.has(key)) { debugLog(`skip seen msg ${msg.id}`); continue; }
|
|
1826
|
-
seenMessages.add(key);
|
|
1902
|
+
} else {
|
|
1903
|
+
debugLog(`poll: skipping event type=${event.type} id=${event.id}`);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1827
1906
|
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
} else {
|
|
1834
|
-
try {
|
|
1835
|
-
decrypted = decryptMessage(msg, unread.taskId);
|
|
1836
|
-
} catch {
|
|
1837
|
-
decrypted = { content: "[decryption failed]", contentType: "text" };
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1907
|
+
// Detect whether the MCP client reliably surfaces channel notifications.
|
|
1908
|
+
// Walk the process tree to find Claude Code with --dangerously-load-development-channels server:pairai-channel.
|
|
1909
|
+
// Falls back to PAIRAI_CHANNEL_NOTIFICATIONS=1 env var (non-Linux or custom setups).
|
|
1910
|
+
const CHANNEL_FLAG = "--dangerously-load-development-channels";
|
|
1911
|
+
const CHANNEL_VALUE = "server:pairai-channel";
|
|
1840
1912
|
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1913
|
+
function detectChannelCapable(): boolean {
|
|
1914
|
+
if (process.env.PAIRAI_CHANNEL_NOTIFICATIONS === "1") {
|
|
1915
|
+
debugLog("detect-channel: PAIRAI_CHANNEL_NOTIFICATIONS=1 (env override)");
|
|
1916
|
+
return true;
|
|
1917
|
+
}
|
|
1918
|
+
if (process.platform !== "linux") {
|
|
1919
|
+
debugLog(`detect-channel: platform=${process.platform} (not linux, skipping /proc walk)`);
|
|
1920
|
+
return false;
|
|
1921
|
+
}
|
|
1922
|
+
try {
|
|
1923
|
+
let pid = String(process.ppid);
|
|
1924
|
+
debugLog(`detect-channel: starting walk from ppid=${pid}`);
|
|
1925
|
+
for (let i = 0; i < 10; i++) {
|
|
1926
|
+
const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf-8").split("\0").filter(Boolean);
|
|
1927
|
+
const bin = cmdline[0] ?? "";
|
|
1928
|
+
debugLog(`detect-channel: pid=${pid} bin=${bin} args=${JSON.stringify(cmdline.slice(1))}`);
|
|
1929
|
+
if (bin === "claude" || bin.endsWith("/claude")) {
|
|
1930
|
+
// Check for our specific channel: --flag server:pairai-channel or --flag=server:pairai-channel
|
|
1931
|
+
let found = false;
|
|
1932
|
+
const channelArgs: string[] = [];
|
|
1933
|
+
for (let j = 1; j < cmdline.length; j++) {
|
|
1934
|
+
const arg = cmdline[j]!;
|
|
1935
|
+
if (arg === CHANNEL_FLAG && cmdline[j + 1]) {
|
|
1936
|
+
channelArgs.push(cmdline[j + 1]!);
|
|
1937
|
+
if (cmdline[j + 1] === CHANNEL_VALUE) found = true;
|
|
1938
|
+
j++; // skip value
|
|
1939
|
+
} else if (arg.startsWith(`${CHANNEL_FLAG}=`)) {
|
|
1940
|
+
const val = arg.slice(CHANNEL_FLAG.length + 1);
|
|
1941
|
+
channelArgs.push(val);
|
|
1942
|
+
if (val === CHANNEL_VALUE) found = true;
|
|
1943
|
+
}
|
|
1858
1944
|
}
|
|
1945
|
+
debugLog(`detect-channel: found claude binary at pid=${pid}, channels=${JSON.stringify(channelArgs)}, looking for="${CHANNEL_VALUE}", match=${found}`);
|
|
1946
|
+
return found;
|
|
1947
|
+
}
|
|
1948
|
+
// Walk up: read ppid from /proc/<pid>/stat
|
|
1949
|
+
// Format: "pid (comm) state ppid ..." — comm can contain spaces/parens,
|
|
1950
|
+
// so find the LAST ")" to skip past it, then parse fields after it.
|
|
1951
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
1952
|
+
const afterComm = stat.slice(stat.lastIndexOf(")") + 2);
|
|
1953
|
+
const nextPid = afterComm.split(" ")[1]; // fields: state ppid ...
|
|
1954
|
+
debugLog(`detect-channel: pid=${pid} → ppid=${nextPid}`);
|
|
1955
|
+
if (!nextPid || nextPid === "1" || nextPid === "0") {
|
|
1956
|
+
debugLog(`detect-channel: reached process tree root (pid=${nextPid}), stopping`);
|
|
1957
|
+
break;
|
|
1859
1958
|
}
|
|
1959
|
+
pid = nextPid;
|
|
1860
1960
|
}
|
|
1961
|
+
debugLog("detect-channel: exhausted process tree without finding claude binary");
|
|
1962
|
+
} catch (err) {
|
|
1963
|
+
debugLog(`detect-channel: error walking /proc: ${(err as Error).message}`);
|
|
1964
|
+
}
|
|
1965
|
+
return false;
|
|
1966
|
+
}
|
|
1967
|
+
const channelCapable = detectChannelCapable();
|
|
1861
1968
|
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1969
|
+
// For non-channel clients: track the highest event ID we've notified for locally,
|
|
1970
|
+
// so we don't re-deliver on each poll cycle. Not used when channelCapable=true.
|
|
1971
|
+
let lastNotifiedEventId = 0;
|
|
1972
|
+
|
|
1973
|
+
async function poll() {
|
|
1974
|
+
try {
|
|
1975
|
+
// Refresh public keys to pick up new connections
|
|
1976
|
+
await loadPublicKeys();
|
|
1977
|
+
|
|
1978
|
+
// Non-channel clients: use local cursor to avoid re-notifying, but don't touch server cursor.
|
|
1979
|
+
// Channel clients: use server cursor (default behavior — omit after=).
|
|
1980
|
+
const afterQs = !channelCapable && lastNotifiedEventId > 0 ? `?after=${lastNotifiedEventId}` : "";
|
|
1981
|
+
const updates = (await hubGet(`/events${afterQs}`)) as {
|
|
1982
|
+
events: Array<{
|
|
1983
|
+
id: number;
|
|
1984
|
+
type: string;
|
|
1985
|
+
taskId: string | null;
|
|
1986
|
+
fromAgentId: string | null;
|
|
1987
|
+
data: Record<string, unknown>;
|
|
1988
|
+
createdAt: string;
|
|
1989
|
+
}>;
|
|
1990
|
+
cursor: number;
|
|
1991
|
+
hasMore: boolean;
|
|
1992
|
+
};
|
|
1993
|
+
|
|
1994
|
+
debugLog(`poll: ${updates.events.length} events, cursor=${updates.cursor}, hasMore=${updates.hasMore}${channelCapable ? "" : `, localCursor=${lastNotifiedEventId}`}`);
|
|
1995
|
+
|
|
1996
|
+
if (updates.events.length === 0) return;
|
|
1997
|
+
|
|
1998
|
+
for (const event of updates.events) {
|
|
1866
1999
|
try {
|
|
1867
|
-
await
|
|
1868
|
-
debugLog(`poll: acked cursor=${updates.cursor}`);
|
|
2000
|
+
await deliverEventNotification(event);
|
|
1869
2001
|
} catch (err) {
|
|
1870
|
-
|
|
2002
|
+
console.error(`[pairai] notification delivery failed for event ${event.id}: ${(err as Error).message}`);
|
|
1871
2003
|
}
|
|
1872
2004
|
}
|
|
1873
2005
|
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
2006
|
+
if (channelCapable) {
|
|
2007
|
+
// Channel clients: ack server-side — notifications are reliably delivered
|
|
2008
|
+
if (updates.cursor > 0) {
|
|
2009
|
+
try {
|
|
2010
|
+
await hubPost("/events/ack", { cursor: updates.cursor });
|
|
2011
|
+
debugLog(`poll: acked cursor=${updates.cursor}`);
|
|
2012
|
+
} catch (err) {
|
|
2013
|
+
debugLog(`poll: ack failed (will retry next cycle): ${(err as Error).message}`);
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
} else {
|
|
2017
|
+
// Non-channel clients: advance local cursor only, leave server cursor for check_updates
|
|
2018
|
+
lastNotifiedEventId = updates.cursor;
|
|
2019
|
+
debugLog(`poll: local cursor advanced to ${lastNotifiedEventId}`);
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
if (updates.hasMore) {
|
|
2023
|
+
setImmediate(poll);
|
|
1879
2024
|
}
|
|
1880
2025
|
} catch (err) {
|
|
1881
2026
|
console.error(`[pairai] poll error: ${(err as Error).message}`);
|
|
@@ -1886,7 +2031,9 @@ async function poll() {
|
|
|
1886
2031
|
// ── Start ────────────────────────────────────────────────────────────────────
|
|
1887
2032
|
|
|
1888
2033
|
await mcp.connect(new StdioServerTransport());
|
|
1889
|
-
console.error(`[pairai] connected. provider=${serveProvider}
|
|
2034
|
+
console.error(`[pairai] connected. provider=${serveProvider} channelNotifications=${channelCapable} agent=${myAgentId || "(loading)"}`);
|
|
2035
|
+
debugLog(`startup: provider=${serveProvider} channelCapable=${channelCapable} (${channelCapable ? "poll acks server cursor" : "poll uses local cursor, check_updates acks"})`);
|
|
2036
|
+
|
|
1890
2037
|
await loadAgentInfo();
|
|
1891
2038
|
if (!myAgentId) {
|
|
1892
2039
|
console.error("[pairai] failed to load agent info from hub. Cannot start polling.");
|