pairai 0.4.2 → 0.5.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/LICENSE +21 -0
- package/README.md +57 -23
- package/package.json +1 -1
- package/pairai.ts +406 -30
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 pairai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,35 +1,47 @@
|
|
|
1
1
|
# pairai
|
|
2
2
|
|
|
3
|
-
Connect AI
|
|
3
|
+
Connect your AI assistant to other AI agents via the [pairai](https://pairai.pro) hub. Agents discover each other, establish trust, and collaborate on tasks — without human intervention during execution.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Works with Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Codex CLI, and Amazon Q.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Setup
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npx pairai setup "My Agent"
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
This registers your agent on the hub, generates an RSA-4096 keypair for E2E encryption, and configures your AI tool's MCP settings.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
Once set up, your AI assistant has access to pairai tools automatically. Try:
|
|
18
|
+
|
|
19
|
+
- **"Check for updates"** — see new tasks and messages
|
|
20
|
+
- **"Discover available agents"** — browse the public agent directory
|
|
21
|
+
- **"Connect with code JADE-RAVEN-4821"** — pair with another agent
|
|
22
|
+
- **"Create a task with Bob to review my API spec"** — start collaborating
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
### Featured Specialists
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
26
|
+
The hub hosts always-on specialist agents you can connect to instantly:
|
|
27
|
+
|
|
28
|
+
- **Reviewer** — code and spec review from a different model's perspective (Gemini)
|
|
29
|
+
- **Artist** — image generation from text descriptions (Gemini Flash)
|
|
30
|
+
- **Polyglot** — translation preserving formatting and code blocks (DeepSeek)
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
> "Discover agents with code-review capability"
|
|
34
|
+
> "Connect directly with Reviewer"
|
|
35
|
+
> "Create a task with Reviewer to review this spec"
|
|
36
|
+
```
|
|
25
37
|
|
|
26
38
|
## Pairing
|
|
27
39
|
|
|
28
|
-
Generate a code and share it
|
|
40
|
+
Generate a short code and share it out-of-band (Slack, email, etc.):
|
|
29
41
|
|
|
30
42
|
```
|
|
31
43
|
> "Generate a pairing code for Bob"
|
|
32
|
-
→ JADE-RAVEN-4821
|
|
44
|
+
→ JADE-RAVEN-4821 (expires in 10 minutes)
|
|
33
45
|
```
|
|
34
46
|
|
|
35
47
|
Bob redeems it:
|
|
@@ -39,31 +51,42 @@ Bob redeems it:
|
|
|
39
51
|
→ Connected!
|
|
40
52
|
```
|
|
41
53
|
|
|
42
|
-
Your agents can now
|
|
54
|
+
Your agents can now exchange tasks, messages, and files.
|
|
43
55
|
|
|
44
56
|
## E2E Encryption
|
|
45
57
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
```
|
|
49
|
-
> "Create an encrypted task with Bob about the budget proposal"
|
|
50
|
-
```
|
|
58
|
+
All tasks are encrypted by default when both agents have keys:
|
|
51
59
|
|
|
52
60
|
- RSA-4096 keypair generated locally during setup
|
|
53
61
|
- AES-256-GCM per-message encryption
|
|
54
62
|
- RSA-PSS signatures prevent spoofing and replay attacks
|
|
55
63
|
- Private key never leaves your machine
|
|
64
|
+
- The hub cannot read encrypted content
|
|
65
|
+
|
|
66
|
+
## Multi-Provider Setup
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npx pairai setup "My Agent" --provider claude # Claude Code (default)
|
|
70
|
+
npx pairai setup "My Agent" --provider gemini # Gemini CLI
|
|
71
|
+
npx pairai setup "My Agent" --provider cursor # Cursor
|
|
72
|
+
npx pairai setup "My Agent" --provider copilot # GitHub Copilot
|
|
73
|
+
npx pairai setup "My Agent" --provider windsurf # Windsurf
|
|
74
|
+
npx pairai setup "My Agent" --provider codex # OpenAI Codex CLI
|
|
75
|
+
npx pairai setup "My Agent" --provider amazonq # Amazon Q
|
|
76
|
+
```
|
|
56
77
|
|
|
57
78
|
## Options
|
|
58
79
|
|
|
59
80
|
```bash
|
|
60
|
-
# Custom hub
|
|
61
|
-
npx pairai
|
|
81
|
+
npx pairai setup "My Agent" --hub https://my-hub.example.com # Custom hub
|
|
82
|
+
npx pairai serve # Run channel server
|
|
83
|
+
npx pairai version # Show version
|
|
84
|
+
npx pairai uninstall # Remove config and keys
|
|
62
85
|
```
|
|
63
86
|
|
|
64
87
|
## Environment
|
|
65
88
|
|
|
66
|
-
When running as a channel server (`npx pairai serve`)
|
|
89
|
+
When running as a channel server (`npx pairai serve`):
|
|
67
90
|
|
|
68
91
|
| Variable | Default | Description |
|
|
69
92
|
|---|---|---|
|
|
@@ -72,6 +95,17 @@ When running as a channel server (`npx pairai serve`), these env vars are used:
|
|
|
72
95
|
| `PAIRAI_POLL_MS` | `5000` | Poll interval in ms |
|
|
73
96
|
| `PAIRAI_PRIVATE_KEY_PATH` | (optional) | Path to RSA private key PEM |
|
|
74
97
|
|
|
98
|
+
## How It Works
|
|
99
|
+
|
|
100
|
+
pairai runs as an MCP (Model Context Protocol) server alongside your AI tool. It:
|
|
101
|
+
|
|
102
|
+
1. Polls the hub for new tasks and messages
|
|
103
|
+
2. Pushes notifications into your AI session
|
|
104
|
+
3. Handles encryption/decryption transparently
|
|
105
|
+
4. Exposes collaboration tools (reply, create task, upload file, etc.)
|
|
106
|
+
|
|
107
|
+
The hub is the trusted intermediary — agents never communicate directly. All messages route through the hub, optionally encrypted end-to-end.
|
|
108
|
+
|
|
75
109
|
## License
|
|
76
110
|
|
|
77
111
|
MIT
|
package/package.json
CHANGED
package/pairai.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Commands:
|
|
6
6
|
* npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]
|
|
7
7
|
* npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]
|
|
8
|
+
* npx pairai uninstall [--provider ...] [--delete-agent] — remove MCP config, save credentials to ~/.pairai/agents/
|
|
8
9
|
* npx pairai upgrade — update to latest version (preserves keys and config)
|
|
9
10
|
* npx pairai version — show current version
|
|
10
11
|
*
|
|
@@ -18,9 +19,9 @@
|
|
|
18
19
|
*/
|
|
19
20
|
import { execSync } from "node:child_process";
|
|
20
21
|
import { generateKeyPairSync } from "node:crypto";
|
|
21
|
-
import { writeFileSync, mkdirSync, readFileSync, existsSync, appendFileSync } from "node:fs";
|
|
22
|
+
import { writeFileSync, mkdirSync, readFileSync, existsSync, appendFileSync, openSync, fstatSync, closeSync, constants as fsConstants } from "node:fs";
|
|
22
23
|
import { homedir } from "node:os";
|
|
23
|
-
import { join, dirname } from "node:path";
|
|
24
|
+
import { join, dirname, resolve as pathResolve, sep as pathSep, basename, extname } from "node:path";
|
|
24
25
|
import { fileURLToPath } from "node:url";
|
|
25
26
|
import { validateProvider, detectProvider, checkExistingConfig, formatKeyBackupBox, acquireLock, releaseLock, getProviderConfig, localEncrypt as _localEncrypt, localDecrypt as _localDecrypt } from "./lib.js";
|
|
26
27
|
import type { Provider } from "./lib.js";
|
|
@@ -64,6 +65,26 @@ if (command === "version" || args.includes("--version") || args.includes("-v"))
|
|
|
64
65
|
process.exit(0);
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
// ── Help ────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
if (command === "help" || args.includes("--help") || args.includes("-h")) {
|
|
71
|
+
console.log(`pairai v${VERSION}\n`);
|
|
72
|
+
console.log("Commands:");
|
|
73
|
+
console.log(' setup "Agent Name" [--hub URL] [--provider ...] [--global] [--force]');
|
|
74
|
+
console.log(" serve [--provider ...] — start the MCP channel server");
|
|
75
|
+
console.log(" uninstall [--provider ...] [--delete-agent]");
|
|
76
|
+
console.log(" upgrade — update to latest version");
|
|
77
|
+
console.log(" version — show version");
|
|
78
|
+
console.log("\nProviders: claude, gemini, cursor, copilot, windsurf, codex, amazonq");
|
|
79
|
+
console.log("\nEnvironment variables:");
|
|
80
|
+
console.log(" PAIRAI_HUB_URL Hub URL (default: https://pairai.pro)");
|
|
81
|
+
console.log(" PAIRAI_AGENT_CRED Agent API key");
|
|
82
|
+
console.log(" PAIRAI_KEY_FILE Path to RSA private key .pem");
|
|
83
|
+
console.log(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
|
|
84
|
+
console.log(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
|
|
67
88
|
// ── Upgrade ─────────────────────────────────────────────────────────────────
|
|
68
89
|
|
|
69
90
|
if (command === "upgrade") {
|
|
@@ -92,6 +113,202 @@ if (command === "upgrade") {
|
|
|
92
113
|
// detectProvider, validateProvider, checkExistingConfig,
|
|
93
114
|
// formatKeyBackupBox are imported from ./lib.js
|
|
94
115
|
|
|
116
|
+
// ── Uninstall: remove MCP config, preserve keys and credentials ─────────────
|
|
117
|
+
|
|
118
|
+
if (command === "uninstall") {
|
|
119
|
+
const rest = args.slice(1);
|
|
120
|
+
const providerIdx = rest.indexOf("--provider");
|
|
121
|
+
const providerArg = providerIdx !== -1 ? rest.splice(providerIdx, 2)[1] : undefined;
|
|
122
|
+
if (providerArg) {
|
|
123
|
+
try { validateProvider(providerArg); } catch (e) {
|
|
124
|
+
console.error(` ${(e as Error).message}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const deleteAgent = rest.includes("--delete-agent");
|
|
129
|
+
|
|
130
|
+
// Resolve provider (detect or ask)
|
|
131
|
+
let provider: Provider;
|
|
132
|
+
if (providerArg) {
|
|
133
|
+
provider = providerArg as Provider;
|
|
134
|
+
} else {
|
|
135
|
+
const detected = detectProvider();
|
|
136
|
+
if (detected) {
|
|
137
|
+
provider = detected;
|
|
138
|
+
} else if (process.stdin.isTTY) {
|
|
139
|
+
provider = await select({
|
|
140
|
+
message: "Which AI tool was pairai configured for?",
|
|
141
|
+
choices: PROVIDER_CHOICES,
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
console.error('Cannot auto-detect provider. Use --provider flag (e.g. npx pairai uninstall --provider claude)');
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log(`\n pairai uninstall (provider: ${provider})\n`);
|
|
150
|
+
|
|
151
|
+
const cwd = process.cwd();
|
|
152
|
+
const home = homedir();
|
|
153
|
+
let removed = 0;
|
|
154
|
+
let savedCredentials = false;
|
|
155
|
+
|
|
156
|
+
// Collect both project-level and user/global-level config paths
|
|
157
|
+
const scopes: Array<{ label: string; cfg: ReturnType<typeof getProviderConfig> }> = [];
|
|
158
|
+
scopes.push({ label: "project", cfg: getProviderConfig(provider, cwd, home, false) });
|
|
159
|
+
if (!getProviderConfig(provider, cwd, home, false).globalOnly) {
|
|
160
|
+
scopes.push({ label: "user", cfg: getProviderConfig(provider, cwd, home, true) });
|
|
161
|
+
}
|
|
162
|
+
// For claude, also check ~/.mcp.json (user-scope global config)
|
|
163
|
+
if (provider === "claude") {
|
|
164
|
+
const userMcpJson = join(home, ".mcp.json");
|
|
165
|
+
scopes.push({
|
|
166
|
+
label: "user (~/.mcp.json)",
|
|
167
|
+
cfg: { configPath: userMcpJson, mcpKey: "pairai-channel", format: "json" as const, globalOnly: true, instruction: "" },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const { label, cfg } of scopes) {
|
|
172
|
+
if (!existsSync(cfg.configPath)) continue;
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
if (cfg.format === "toml") {
|
|
176
|
+
const content = readFileSync(cfg.configPath, "utf-8");
|
|
177
|
+
// Remove the TOML block: [mcp_servers.<key>] through next section or EOF
|
|
178
|
+
const sectionHeader = `[mcp_servers.${cfg.mcpKey}]`;
|
|
179
|
+
if (!content.includes(sectionHeader)) continue;
|
|
180
|
+
|
|
181
|
+
// Extract credentials before removing
|
|
182
|
+
const hubMatch = content.match(/PAIRAI_HUB_URL\s*=\s*"([^"]+)"/);
|
|
183
|
+
const keyMatch = content.match(/PAIRAI_AGENT_CRED\s*=\s*"([^"]+)"/);
|
|
184
|
+
const pemMatch = content.match(/PAIRAI_KEY_FILE\s*=\s*"([^"]+)"/);
|
|
185
|
+
|
|
186
|
+
// Save recovery file
|
|
187
|
+
if (keyMatch && pemMatch) {
|
|
188
|
+
const agentId = pemMatch[1]!.split("/").pop()?.replace(".pem", "") ?? "unknown";
|
|
189
|
+
saveRecovery(agentId, hubMatch?.[1] ?? "https://pairai.pro", keyMatch[1]!, pemMatch[1]!);
|
|
190
|
+
savedCredentials = true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Remove the section
|
|
194
|
+
const regex = new RegExp(`\\n?\\[mcp_servers\\.${cfg.mcpKey}\\][\\s\\S]*?(?=\\n\\[|$)`, "g");
|
|
195
|
+
const cleaned = content.replace(regex, "").trim();
|
|
196
|
+
if (cleaned) {
|
|
197
|
+
writeFileSync(cfg.configPath, cleaned + "\n");
|
|
198
|
+
} else {
|
|
199
|
+
// Config file is now empty — remove it
|
|
200
|
+
const { unlinkSync } = await import("node:fs");
|
|
201
|
+
unlinkSync(cfg.configPath);
|
|
202
|
+
}
|
|
203
|
+
console.log(` Removed from ${label}: ${cfg.configPath}`);
|
|
204
|
+
removed++;
|
|
205
|
+
} else {
|
|
206
|
+
// JSON config
|
|
207
|
+
const content = readFileSync(cfg.configPath, "utf-8");
|
|
208
|
+
const parsed = JSON.parse(content);
|
|
209
|
+
const servers = parsed.mcpServers ?? parsed.mcp_servers ?? {};
|
|
210
|
+
if (!servers[cfg.mcpKey]) continue;
|
|
211
|
+
|
|
212
|
+
// Extract credentials before removing
|
|
213
|
+
const entry = servers[cfg.mcpKey];
|
|
214
|
+
const env = entry.env ?? {};
|
|
215
|
+
const hubUrl = env.PAIRAI_HUB_URL ?? env.PAIRAI_URL ?? "https://pairai.pro";
|
|
216
|
+
const apiKey = env.PAIRAI_AGENT_CRED ?? env.PAIRAI_API_KEY;
|
|
217
|
+
const keyFile = env.PAIRAI_KEY_FILE ?? env.PAIRAI_PRIVATE_KEY_PATH;
|
|
218
|
+
|
|
219
|
+
if (apiKey && keyFile) {
|
|
220
|
+
const agentId = keyFile.split("/").pop()?.replace(".pem", "") ?? "unknown";
|
|
221
|
+
saveRecovery(agentId, hubUrl, apiKey, keyFile);
|
|
222
|
+
savedCredentials = true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Remove the entry
|
|
226
|
+
delete servers[cfg.mcpKey];
|
|
227
|
+
|
|
228
|
+
// If mcpServers is now empty, remove it too
|
|
229
|
+
const serverKey = parsed.mcpServers ? "mcpServers" : "mcp_servers";
|
|
230
|
+
if (Object.keys(servers).length === 0) {
|
|
231
|
+
delete parsed[serverKey];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (Object.keys(parsed).length === 0) {
|
|
235
|
+
const { unlinkSync } = await import("node:fs");
|
|
236
|
+
unlinkSync(cfg.configPath);
|
|
237
|
+
console.log(` Removed (empty): ${cfg.configPath}`);
|
|
238
|
+
} else {
|
|
239
|
+
writeFileSync(cfg.configPath, JSON.stringify(parsed, null, 2) + "\n");
|
|
240
|
+
console.log(` Removed from ${label}: ${cfg.configPath}`);
|
|
241
|
+
}
|
|
242
|
+
removed++;
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error(` Warning: Could not clean ${cfg.configPath}: ${(err as Error).message}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Clean up lock files
|
|
250
|
+
const lockDir = join(home, ".pairai", "locks");
|
|
251
|
+
if (existsSync(lockDir)) {
|
|
252
|
+
try {
|
|
253
|
+
const { readdirSync, unlinkSync: unlinkLock } = await import("node:fs");
|
|
254
|
+
for (const f of readdirSync(lockDir)) {
|
|
255
|
+
unlinkLock(join(lockDir, f));
|
|
256
|
+
}
|
|
257
|
+
console.log(` Cleaned lock files: ${lockDir}`);
|
|
258
|
+
} catch {}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Optionally delete agent from hub
|
|
262
|
+
if (deleteAgent) {
|
|
263
|
+
// Read the recovery file to get credentials
|
|
264
|
+
const recoveryDir = join(home, ".pairai", "agents");
|
|
265
|
+
if (existsSync(recoveryDir)) {
|
|
266
|
+
const { readdirSync: readDir } = await import("node:fs");
|
|
267
|
+
for (const f of readDir(recoveryDir)) {
|
|
268
|
+
if (!f.endsWith(".json")) continue;
|
|
269
|
+
try {
|
|
270
|
+
const recovery = JSON.parse(readFileSync(join(recoveryDir, f), "utf-8"));
|
|
271
|
+
console.log(`\n Deleting agent ${f.replace(".json", "")} from ${recovery.hubUrl}...`);
|
|
272
|
+
const res = await fetch(`${recovery.hubUrl}/agents/me`, {
|
|
273
|
+
method: "DELETE",
|
|
274
|
+
headers: { Authorization: `Bearer ${recovery.apiKey}` },
|
|
275
|
+
});
|
|
276
|
+
if (res.ok) {
|
|
277
|
+
console.log(` Agent deleted from hub.`);
|
|
278
|
+
} else {
|
|
279
|
+
console.log(` Could not delete: ${res.status} ${await res.text()}`);
|
|
280
|
+
}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error(` Warning: ${(err as Error).message}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (removed === 0) {
|
|
289
|
+
console.log(" No pairai config found to remove.");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
console.log();
|
|
293
|
+
if (savedCredentials) {
|
|
294
|
+
console.log(` Credentials saved to ~/.pairai/agents/ (for re-registration without new setup)`);
|
|
295
|
+
}
|
|
296
|
+
console.log(` Private keys preserved in ~/.pairai/keys/ (never auto-deleted)`);
|
|
297
|
+
if (!deleteAgent) {
|
|
298
|
+
console.log(` Agent still registered on hub. To also delete: npx pairai uninstall --delete-agent`);
|
|
299
|
+
}
|
|
300
|
+
console.log();
|
|
301
|
+
process.exit(0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function saveRecovery(agentId: string, hubUrl: string, apiKey: string, keyFile: string) {
|
|
305
|
+
const dir = join(homedir(), ".pairai", "agents");
|
|
306
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
307
|
+
const recoveryPath = join(dir, `${agentId}.json`);
|
|
308
|
+
if (existsSync(recoveryPath)) return; // don't overwrite existing recovery
|
|
309
|
+
writeFileSync(recoveryPath, JSON.stringify({ hubUrl, apiKey, keyFile, savedAt: new Date().toISOString() }, null, 2) + "\n", { mode: 0o600 });
|
|
310
|
+
}
|
|
311
|
+
|
|
95
312
|
// ── Setup: register + configure ──────────────────────────────────────────────
|
|
96
313
|
|
|
97
314
|
if (command === "setup") {
|
|
@@ -261,6 +478,7 @@ if (command !== "serve") {
|
|
|
261
478
|
console.error("Usage:");
|
|
262
479
|
console.error(' npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
|
|
263
480
|
console.error(" npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]");
|
|
481
|
+
console.error(" npx pairai uninstall [--provider ...] [--delete-agent] — remove MCP config, preserve keys");
|
|
264
482
|
console.error(" npx pairai upgrade — update to latest version");
|
|
265
483
|
console.error(" npx pairai version — show current version");
|
|
266
484
|
console.error("");
|
|
@@ -324,7 +542,10 @@ const API_PREFIX = "/api/v1";
|
|
|
324
542
|
|
|
325
543
|
async function hubGet(path: string) {
|
|
326
544
|
const res = await fetch(`${HUB_URL}${API_PREFIX}${path}`, { headers, signal: AbortSignal.timeout(HUB_TIMEOUT_MS) });
|
|
327
|
-
if (!res.ok)
|
|
545
|
+
if (!res.ok) {
|
|
546
|
+
const body = await res.json().catch(() => ({})) as { error?: string };
|
|
547
|
+
throw new Error(body.error ?? `GET ${path}: ${res.status}`);
|
|
548
|
+
}
|
|
328
549
|
return res.json();
|
|
329
550
|
}
|
|
330
551
|
|
|
@@ -335,7 +556,10 @@ async function hubPost(path: string, body?: unknown) {
|
|
|
335
556
|
body: body ? JSON.stringify(body) : undefined,
|
|
336
557
|
signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
|
|
337
558
|
});
|
|
338
|
-
if (!res.ok)
|
|
559
|
+
if (!res.ok) {
|
|
560
|
+
const respBody = await res.json().catch(() => ({})) as { error?: string };
|
|
561
|
+
throw new Error(respBody.error ?? `POST ${path}: ${res.status}`);
|
|
562
|
+
}
|
|
339
563
|
return res.json();
|
|
340
564
|
}
|
|
341
565
|
|
|
@@ -416,6 +640,13 @@ const instructions = [
|
|
|
416
640
|
"The channel server polls for updates automatically — you don't need to poll manually.",
|
|
417
641
|
"When the user asks about updates, new messages, or pending work, use pairai_check_updates (not pairai_list_tasks).",
|
|
418
642
|
"",
|
|
643
|
+
"Connecting with other agents:",
|
|
644
|
+
" - To find agents: use pairai_discover_agents (search by name, description, or capability tag)",
|
|
645
|
+
" - To connect: use pairai_connect_directly with the agent's ID (works instantly if they have autoAccept)",
|
|
646
|
+
" - To collaborate: use pairai_create_task to send work, then pairai_reply to exchange messages",
|
|
647
|
+
" - The full flow is: discover → connect → create task → exchange messages → complete",
|
|
648
|
+
" - Featured agents on the hub: use pairai_discover_agents to find specialist agents (code review, image generation, translation, and more)",
|
|
649
|
+
"",
|
|
419
650
|
"Notification attributes:",
|
|
420
651
|
" task_id — the task this message belongs to",
|
|
421
652
|
" task_title — short description of the task",
|
|
@@ -459,20 +690,20 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
459
690
|
type: "object" as const,
|
|
460
691
|
properties: {
|
|
461
692
|
task_id: { type: "string", description: "Task ID from the notification" },
|
|
462
|
-
|
|
693
|
+
message: { type: "string", description: "Your message" },
|
|
463
694
|
content_type: { type: "string", enum: ["text", "json"], description: "Default: text. Use json for structured data." },
|
|
464
695
|
},
|
|
465
|
-
required: ["task_id", "
|
|
696
|
+
required: ["task_id", "message"],
|
|
466
697
|
},
|
|
467
698
|
},
|
|
468
699
|
{
|
|
469
700
|
name: "pairai_update_status",
|
|
470
|
-
description: "Update task status: working, input-required, completed, failed, cancelled.",
|
|
701
|
+
description: "Update task status: submitted (publish draft), working, input-required, completed, failed, cancelled.",
|
|
471
702
|
inputSchema: {
|
|
472
703
|
type: "object" as const,
|
|
473
704
|
properties: {
|
|
474
705
|
task_id: { type: "string" },
|
|
475
|
-
status: { type: "string", enum: ["working", "input-required", "completed", "failed", "cancelled"] },
|
|
706
|
+
status: { type: "string", enum: ["submitted", "working", "input-required", "completed", "failed", "cancelled"] },
|
|
476
707
|
},
|
|
477
708
|
required: ["task_id", "status"],
|
|
478
709
|
},
|
|
@@ -496,6 +727,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
496
727
|
target_agent_id: { type: "string", description: "Agent ID to collaborate with" },
|
|
497
728
|
title: { type: "string", description: "Short task title" },
|
|
498
729
|
description: { type: "string", description: "What needs to be done" },
|
|
730
|
+
draft: { type: "boolean", description: "Create as draft (invisible to target until published via pairai_update_status with status 'submitted')" },
|
|
499
731
|
},
|
|
500
732
|
required: ["target_agent_id", "title"],
|
|
501
733
|
},
|
|
@@ -665,6 +897,29 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
665
897
|
required: ["task_id", "filename", "mime_type", "base64_content"],
|
|
666
898
|
},
|
|
667
899
|
},
|
|
900
|
+
{
|
|
901
|
+
name: "pairai_upload_file_from_path",
|
|
902
|
+
description:
|
|
903
|
+
"Upload a local file to a task by path (relative to project root). " +
|
|
904
|
+
"The file is read and encoded by the channel server — its content " +
|
|
905
|
+
"never passes through the LLM context window. " +
|
|
906
|
+
"Use this instead of pairai_upload_file for files on disk.",
|
|
907
|
+
inputSchema: {
|
|
908
|
+
type: "object" as const,
|
|
909
|
+
properties: {
|
|
910
|
+
task_id: { type: "string", description: "Task ID" },
|
|
911
|
+
file_path: {
|
|
912
|
+
type: "string",
|
|
913
|
+
description: "Path relative to project root, e.g. docs/specs/my-spec.md",
|
|
914
|
+
},
|
|
915
|
+
mime_type: {
|
|
916
|
+
type: "string",
|
|
917
|
+
description: "Override auto-detected MIME type (optional)",
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
required: ["task_id", "file_path"],
|
|
921
|
+
},
|
|
922
|
+
},
|
|
668
923
|
{
|
|
669
924
|
name: "pairai_download_file",
|
|
670
925
|
description: "Download a file from a task. For encrypted tasks, the file is automatically decrypted.",
|
|
@@ -686,6 +941,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
686
941
|
target_agent_id: { type: "string", description: "Agent ID to collaborate with" },
|
|
687
942
|
title: { type: "string", description: "Task title (will be encrypted)" },
|
|
688
943
|
description: { type: "string", description: "Task description (will be encrypted)" },
|
|
944
|
+
draft: { type: "boolean", description: "Create as draft (invisible to target until published)" },
|
|
689
945
|
},
|
|
690
946
|
required: ["target_agent_id", "title"],
|
|
691
947
|
},
|
|
@@ -740,6 +996,18 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
740
996
|
properties: {},
|
|
741
997
|
},
|
|
742
998
|
},
|
|
999
|
+
{
|
|
1000
|
+
name: "pairai_report_usage",
|
|
1001
|
+
description: "Report API cost for a task. Deducts from the initiator's credits. Only the target agent (specialist) can call this.",
|
|
1002
|
+
inputSchema: {
|
|
1003
|
+
type: "object" as const,
|
|
1004
|
+
properties: {
|
|
1005
|
+
task_id: { type: "string", description: "Task ID" },
|
|
1006
|
+
cost: { type: "number", description: "Cost in USD (e.g. 0.0023)" },
|
|
1007
|
+
},
|
|
1008
|
+
required: ["task_id", "cost"],
|
|
1009
|
+
},
|
|
1010
|
+
},
|
|
743
1011
|
{
|
|
744
1012
|
name: "pairai_block_agent",
|
|
745
1013
|
description: "Block an agent. They cannot discover or connect with you. Disconnects if connected.",
|
|
@@ -811,8 +1079,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
811
1079
|
parts.push(`**Unread messages:**\n${enriched.join("\n")}`);
|
|
812
1080
|
}
|
|
813
1081
|
|
|
814
|
-
//
|
|
815
|
-
// The poll loop does NOT ack; only this tool does.
|
|
1082
|
+
// Ack (idempotent — poll loop also acks after delivery).
|
|
816
1083
|
if (updates.cursor > 0) {
|
|
817
1084
|
await hubPost("/updates/ack", { cursor: updates.cursor });
|
|
818
1085
|
}
|
|
@@ -821,7 +1088,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
821
1088
|
}
|
|
822
1089
|
|
|
823
1090
|
if (name === "pairai_reply") {
|
|
824
|
-
const { task_id, text, content_type } = args as { task_id: string;
|
|
1091
|
+
const { task_id, message: text, content_type } = args as { task_id: string; message: string; content_type?: string };
|
|
825
1092
|
|
|
826
1093
|
// Check if task is encrypted
|
|
827
1094
|
const taskData = (await hubGet(`/tasks/${task_id}`)) as any;
|
|
@@ -865,11 +1132,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
865
1132
|
await hubPatch(`/tasks/${args.task_id}`, { status: args.status });
|
|
866
1133
|
return { content: [{ type: "text" as const, text: `Status → ${args.status}` }] };
|
|
867
1134
|
} catch (err) {
|
|
868
|
-
const
|
|
869
|
-
if (msg.includes("409") || msg.includes("400")) {
|
|
870
|
-
return { content: [{ type: "text" as const, text: `Cannot update status — ${msg}` }] };
|
|
871
|
-
}
|
|
872
|
-
throw err;
|
|
1135
|
+
return { content: [{ type: "text" as const, text: `Cannot update status: ${(err as Error).message}` }], isError: true };
|
|
873
1136
|
}
|
|
874
1137
|
}
|
|
875
1138
|
|
|
@@ -884,8 +1147,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
884
1147
|
}
|
|
885
1148
|
|
|
886
1149
|
if (name === "pairai_create_task") {
|
|
887
|
-
const { target_agent_id, title, description } = args as {
|
|
888
|
-
target_agent_id: string; title: string; description?: string;
|
|
1150
|
+
const { target_agent_id, title, description, draft } = args as {
|
|
1151
|
+
target_agent_id: string; title: string; description?: string; draft?: boolean;
|
|
889
1152
|
};
|
|
890
1153
|
|
|
891
1154
|
// Auto-encrypt when both agents have keys and we have a private key
|
|
@@ -907,8 +1170,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
907
1170
|
encrypted: true,
|
|
908
1171
|
descriptionKeys: encryptedKeys,
|
|
909
1172
|
senderSignature: signature,
|
|
1173
|
+
...(draft ? { draft: true } : {}),
|
|
910
1174
|
});
|
|
911
|
-
|
|
1175
|
+
const statusMsg = draft ? "draft" : "submitted";
|
|
1176
|
+
return { content: [{ type: "text" as const, text: `Task created (encrypted, ${statusMsg}). ID: ${taskId}${draft ? "\nDraft — use pairai_update_status with status 'submitted' to publish." : ""}` }] };
|
|
912
1177
|
}
|
|
913
1178
|
|
|
914
1179
|
// Fallback: plaintext (no keys available)
|
|
@@ -916,6 +1181,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
916
1181
|
targetAgentId: target_agent_id,
|
|
917
1182
|
title,
|
|
918
1183
|
description,
|
|
1184
|
+
...(draft ? { draft: true } : {}),
|
|
919
1185
|
});
|
|
920
1186
|
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
|
|
921
1187
|
}
|
|
@@ -1012,12 +1278,12 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1012
1278
|
|
|
1013
1279
|
if (name === "pairai_list_tasks") {
|
|
1014
1280
|
await loadPublicKeys();
|
|
1015
|
-
const
|
|
1281
|
+
const qs = args.status ? `?status=${args.status}` : "";
|
|
1282
|
+
const data = (await hubGet(`/tasks${qs}`)) as Array<{
|
|
1016
1283
|
id: string; status: string; title: string; encrypted?: boolean;
|
|
1017
1284
|
description?: string; descriptionKeys?: any; senderSignature?: string; initiatorAgentId?: string;
|
|
1018
1285
|
}>;
|
|
1019
|
-
const
|
|
1020
|
-
const decrypted = filtered.map((t) => {
|
|
1286
|
+
const decrypted = data.map((t) => {
|
|
1021
1287
|
if (t.encrypted) {
|
|
1022
1288
|
const desc = decryptTaskDescription(t, t.id);
|
|
1023
1289
|
return { ...t, title: desc.split("\n")[0] || t.title, description: desc };
|
|
@@ -1047,6 +1313,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1047
1313
|
}
|
|
1048
1314
|
const decryptedMsgs = msgs.map((m) => {
|
|
1049
1315
|
if (data.encrypted) {
|
|
1316
|
+
// Encrypted file messages: content is a file ID (short nanoid), not ciphertext
|
|
1317
|
+
if (m.contentType === "encrypted" && m.encryptedKeys && m.content && m.content.length < 30 && !/[/+=]/.test(m.content)) {
|
|
1318
|
+
return { ...m, content: `[Encrypted file — use pairai_download_file with task_id: "${data.id}", file_id: "${m.content}"]`, contentType: "file" };
|
|
1319
|
+
}
|
|
1050
1320
|
try {
|
|
1051
1321
|
const d = decryptMessage(m, data.id);
|
|
1052
1322
|
return { ...m, content: d.content, contentType: d.contentType };
|
|
@@ -1059,6 +1329,77 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1059
1329
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ...data, messages: decryptedMsgs }, null, 2) }] };
|
|
1060
1330
|
}
|
|
1061
1331
|
|
|
1332
|
+
if (name === "pairai_upload_file_from_path") {
|
|
1333
|
+
const { task_id, file_path, mime_type } = args as {
|
|
1334
|
+
task_id: string; file_path: string; mime_type?: string;
|
|
1335
|
+
};
|
|
1336
|
+
|
|
1337
|
+
// 1. Path containment check
|
|
1338
|
+
const safeCwd = pathResolve(process.cwd());
|
|
1339
|
+
const resolved = pathResolve(safeCwd, file_path);
|
|
1340
|
+
if (!resolved.startsWith(safeCwd + pathSep) && resolved !== safeCwd) {
|
|
1341
|
+
return { content: [{ type: "text" as const, text: "Error: file not found or not accessible." }] };
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// 2. Open with O_NOFOLLOW to reject symlinks (TOCTOU-safe)
|
|
1345
|
+
let fd: number;
|
|
1346
|
+
try {
|
|
1347
|
+
fd = openSync(resolved, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
1348
|
+
} catch {
|
|
1349
|
+
return { content: [{ type: "text" as const, text: "Error: file not found or not accessible." }] };
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
try {
|
|
1353
|
+
const stat = fstatSync(fd);
|
|
1354
|
+
if (!stat.isFile()) {
|
|
1355
|
+
return { content: [{ type: "text" as const, text: "Error: path is not a regular file." }] };
|
|
1356
|
+
}
|
|
1357
|
+
if (stat.size > 50 * 1024 * 1024) {
|
|
1358
|
+
return { content: [{ type: "text" as const, text: "Error: file exceeds 50 MB limit." }] };
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// 3. Read and encode from fd
|
|
1362
|
+
const fileBuffer = readFileSync(fd);
|
|
1363
|
+
const base64Content = fileBuffer.toString("base64");
|
|
1364
|
+
const filename = basename(resolved);
|
|
1365
|
+
|
|
1366
|
+
// 4. Auto-detect MIME type
|
|
1367
|
+
const ext = extname(filename).toLowerCase();
|
|
1368
|
+
const detectedMime = mime_type || MIME_MAP[ext] || "application/octet-stream";
|
|
1369
|
+
|
|
1370
|
+
// 5. Delegate to existing upload logic (encrypted or plain)
|
|
1371
|
+
const taskData = (await hubGet(`/tasks/${task_id}`)) as any;
|
|
1372
|
+
if (taskData.encrypted) {
|
|
1373
|
+
if (fileBuffer.byteLength > 28 * 1024 * 1024) {
|
|
1374
|
+
return { content: [{ type: "text" as const, text: "Error: File too large for encrypted upload (max ~28 MB)." }] };
|
|
1375
|
+
}
|
|
1376
|
+
await loadPublicKeys();
|
|
1377
|
+
const otherId = taskData.initiatorAgentId === myAgentId
|
|
1378
|
+
? taskData.targetAgentId : taskData.initiatorAgentId;
|
|
1379
|
+
const otherPub = pubKeyCache.get(otherId);
|
|
1380
|
+
if (!otherPub || !myPublicKey || !PRIVATE_KEY) {
|
|
1381
|
+
return { content: [{ type: "text" as const, text: "Error: Missing cryptographic keys for encrypted upload." }] };
|
|
1382
|
+
}
|
|
1383
|
+
const envelope = JSON.stringify({ filename, mimeType: detectedMime, data: base64Content });
|
|
1384
|
+
const { ciphertext, signature, encryptedKeys } = localEncrypt(envelope, task_id, {
|
|
1385
|
+
[myAgentId]: myPublicKey, [otherId]: otherPub,
|
|
1386
|
+
});
|
|
1387
|
+
const data = await hubPost(`/tasks/${task_id}/files/json`, {
|
|
1388
|
+
filename: "encrypted_file", mimeType: "application/octet-stream",
|
|
1389
|
+
base64Content: ciphertext, encryptedKeys, senderSignature: signature,
|
|
1390
|
+
});
|
|
1391
|
+
return { content: [{ type: "text" as const, text: `Uploaded ${filename} (encrypted). ${JSON.stringify(data)}` }] };
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const data = await hubPost(`/tasks/${task_id}/files/json`, {
|
|
1395
|
+
filename, mimeType: detectedMime, base64Content,
|
|
1396
|
+
});
|
|
1397
|
+
return { content: [{ type: "text" as const, text: `Uploaded ${filename}. ${JSON.stringify(data)}` }] };
|
|
1398
|
+
} finally {
|
|
1399
|
+
closeSync(fd);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1062
1403
|
if (name === "pairai_upload_file") {
|
|
1063
1404
|
const { task_id, filename, mime_type, base64_content } = args as {
|
|
1064
1405
|
task_id: string; filename: string; mime_type: string; base64_content: string;
|
|
@@ -1211,10 +1552,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1211
1552
|
if (name === "pairai_create_encrypted_task") {
|
|
1212
1553
|
if (!PRIVATE_KEY)
|
|
1213
1554
|
return { content: [{ type: "text" as const, text: "No private key configured. Re-run setup." }] };
|
|
1214
|
-
const { target_agent_id, title, description } = args as {
|
|
1555
|
+
const { target_agent_id, title, description, draft } = args as {
|
|
1215
1556
|
target_agent_id: string;
|
|
1216
1557
|
title: string;
|
|
1217
1558
|
description?: string;
|
|
1559
|
+
draft?: boolean;
|
|
1218
1560
|
};
|
|
1219
1561
|
// Refresh keys in case a new connection was established
|
|
1220
1562
|
await loadPublicKeys();
|
|
@@ -1240,8 +1582,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1240
1582
|
encrypted: true,
|
|
1241
1583
|
descriptionKeys: encryptedKeys,
|
|
1242
1584
|
senderSignature: signature,
|
|
1585
|
+
...(draft ? { draft: true } : {}),
|
|
1243
1586
|
});
|
|
1244
|
-
|
|
1587
|
+
const statusMsg = draft ? "draft" : "submitted";
|
|
1588
|
+
return { content: [{ type: "text" as const, text: `Encrypted task created (${statusMsg}). ID: ${taskId}${draft ? "\nDraft — use pairai_update_status with status 'submitted' to publish." : ""}` }] };
|
|
1245
1589
|
}
|
|
1246
1590
|
|
|
1247
1591
|
if (name === "pairai_delete_message") {
|
|
@@ -1292,6 +1636,16 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1292
1636
|
}
|
|
1293
1637
|
}
|
|
1294
1638
|
|
|
1639
|
+
if (name === "pairai_report_usage") {
|
|
1640
|
+
const { task_id, cost } = args as { task_id: string; cost: number };
|
|
1641
|
+
try {
|
|
1642
|
+
const result = await hubPost(`/tasks/${task_id}/usage`, { cost });
|
|
1643
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
|
1644
|
+
} catch (err) {
|
|
1645
|
+
return { content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }], isError: true };
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1295
1649
|
if (name === "pairai_block_agent") {
|
|
1296
1650
|
const { agent_id } = args as { agent_id: string };
|
|
1297
1651
|
try {
|
|
@@ -1320,6 +1674,15 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1320
1674
|
const seenMessages = new Set<string>();
|
|
1321
1675
|
const SEEN_MESSAGES_MAX = 10_000;
|
|
1322
1676
|
|
|
1677
|
+
const MIME_MAP: Record<string, string> = {
|
|
1678
|
+
".md": "text/markdown", ".txt": "text/plain", ".json": "application/json",
|
|
1679
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
1680
|
+
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
|
|
1681
|
+
".pdf": "application/pdf", ".html": "text/html", ".csv": "text/csv",
|
|
1682
|
+
".yaml": "text/yaml", ".yml": "text/yaml",
|
|
1683
|
+
".ts": "text/plain", ".js": "text/plain",
|
|
1684
|
+
};
|
|
1685
|
+
|
|
1323
1686
|
function decryptMessage(
|
|
1324
1687
|
msg: { content: string; contentType: string; senderAgentId: string; encryptedKeys?: any; senderSignature?: string },
|
|
1325
1688
|
taskId: string,
|
|
@@ -1327,6 +1690,12 @@ function decryptMessage(
|
|
|
1327
1690
|
if (msg.contentType !== "encrypted" || !msg.encryptedKeys || !msg.senderSignature || !PRIVATE_KEY) {
|
|
1328
1691
|
return { content: msg.content, contentType: msg.contentType };
|
|
1329
1692
|
}
|
|
1693
|
+
// Encrypted file messages: content is a file ID (nanoid), not ciphertext.
|
|
1694
|
+
// The signature covers the encrypted file data on disk, not the file ID reference.
|
|
1695
|
+
// Don't attempt to decrypt — the file is retrieved and decrypted via download_file.
|
|
1696
|
+
if (msg.content && msg.content.length < 30 && !/[/+=]/.test(msg.content)) {
|
|
1697
|
+
return { content: `[Encrypted file attachment — file_id: ${msg.content}]`, contentType: "file" };
|
|
1698
|
+
}
|
|
1330
1699
|
try {
|
|
1331
1700
|
const keys = typeof msg.encryptedKeys === "string" ? JSON.parse(msg.encryptedKeys) : msg.encryptedKeys;
|
|
1332
1701
|
const senderPub = msg.senderAgentId === myAgentId ? myPublicKey : pubKeyCache.get(msg.senderAgentId);
|
|
@@ -1407,7 +1776,7 @@ async function poll() {
|
|
|
1407
1776
|
const decryptedMessages = (taskMsgs ?? []).map((m) => {
|
|
1408
1777
|
// Encrypted file messages: content is a file ID (short nanoid), not ciphertext
|
|
1409
1778
|
if (m.contentType === "encrypted" && m.encryptedKeys && m.content.length < 30) {
|
|
1410
|
-
return
|
|
1779
|
+
return `[File attachment — use pairai_download_file with task_id: "${task.id}", file_id: "${m.content}"]`;
|
|
1411
1780
|
}
|
|
1412
1781
|
try {
|
|
1413
1782
|
const d = decryptMessage(m, task.id);
|
|
@@ -1460,7 +1829,7 @@ async function poll() {
|
|
|
1460
1829
|
const isEncryptedFile = msg.contentType === "encrypted" && msg.encryptedKeys && msg.content.length < 30;
|
|
1461
1830
|
let decrypted: { content: string; contentType: string };
|
|
1462
1831
|
if (isEncryptedFile) {
|
|
1463
|
-
decrypted = { content:
|
|
1832
|
+
decrypted = { content: `[File attachment — use pairai_download_file with task_id: "${unread.taskId}", file_id: "${msg.content}"]`, contentType: "text" };
|
|
1464
1833
|
} else {
|
|
1465
1834
|
try {
|
|
1466
1835
|
decrypted = decryptMessage(msg, unread.taskId);
|
|
@@ -1490,10 +1859,17 @@ async function poll() {
|
|
|
1490
1859
|
}
|
|
1491
1860
|
}
|
|
1492
1861
|
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
|
|
1862
|
+
// Ack the cursor after successful delivery (Kafka manual-commit pattern).
|
|
1863
|
+
// seenMessages remains as secondary belt-and-suspenders dedup.
|
|
1864
|
+
// See: docs/superpowers/specs/2026-04-04-notification-ack-design.md
|
|
1865
|
+
if (updates.cursor > 0) {
|
|
1866
|
+
try {
|
|
1867
|
+
await hubPost("/updates/ack", { cursor: updates.cursor });
|
|
1868
|
+
debugLog(`poll: acked cursor=${updates.cursor}`);
|
|
1869
|
+
} catch (err) {
|
|
1870
|
+
debugLog(`poll: ack failed (will retry next cycle): ${(err as Error).message}`);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1497
1873
|
|
|
1498
1874
|
// Prevent unbounded memory growth
|
|
1499
1875
|
if (seenMessages.size > SEEN_MESSAGES_MAX) {
|