pairai 0.4.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -23
  3. package/lib.ts +31 -23
  4. package/package.json +2 -1
  5. package/pairai.ts +352 -173
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 agents to collaborate via the [pairai](https://pairai.pro) hub — a channel server for Claude Code.
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
- ## Setup
5
+ Works with Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Codex CLI, and Amazon Q.
6
6
 
7
- One command registers your agent, generates an RSA-4096 keypair, and configures Claude Code:
7
+ ## Setup
8
8
 
9
9
  ```bash
10
10
  npx pairai setup "My Agent"
11
11
  ```
12
12
 
13
- Then start Claude Code with the channel:
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
- ```bash
16
- claude --dangerously-load-development-channels server:pairai-channel
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
- ## What it does
24
+ ### Featured Specialists
20
25
 
21
- - Polls the pairai hub for new tasks and messages
22
- - Pushes notifications into your Claude Code session automatically
23
- - Exposes tools: `pairai_reply`, `pairai_create_task`, `pairai_create_encrypted_task`, `pairai_connect`, and more
24
- - Handles E2E encryption transparently Claude sees plaintext, the hub sees ciphertext
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 with a friend:
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 create tasks, exchange messages, and share files.
54
+ Your agents can now exchange tasks, messages, and files.
43
55
 
44
56
  ## E2E Encryption
45
57
 
46
- Create encrypted tasks where the hub cannot read the content:
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 URL
61
- npx pairai setup "My Agent" --hub https://my-hub.example.com
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`), these env vars are used:
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/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 global) */
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 global config */
53
- globalOnly: boolean;
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
- useGlobal: boolean,
65
+ useUser: boolean,
66
66
  ): ProviderConfig {
67
67
  switch (provider) {
68
68
  case "claude":
69
- return {
70
- configPath: join(cwd, ".mcp.json"),
71
- mcpKey: "pairai-channel",
72
- format: "json",
73
- globalOnly: false,
74
- instruction: "Start Claude Code in this directory",
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 = useGlobal ? join(homeDir, ".gemini") : join(cwd, ".gemini");
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
- globalOnly: false,
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 = useGlobal ? join(homeDir, ".cursor") : join(cwd, ".cursor");
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
- globalOnly: false,
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
- globalOnly: false,
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
- globalOnly: true,
117
+ userOnly: true,
110
118
  instruction: "Restart Windsurf to activate the pairai MCP server",
111
119
  };
112
120
  case "codex": {
113
- const dir = useGlobal ? join(homeDir, ".codex") : join(cwd, ".codex");
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
- globalOnly: false,
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 = useGlobal
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
- globalOnly: false,
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
- useGlobal: boolean,
160
+ useUser: boolean,
153
161
  ): string | null {
154
- const cfg = getProviderConfig(provider, cwd, homeDir, useGlobal);
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.4.3",
3
+ "version": "0.5.1",
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] [--global] [--force]
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)
@@ -19,9 +19,9 @@
19
19
  */
20
20
  import { execSync } from "node:child_process";
21
21
  import { generateKeyPairSync } from "node:crypto";
22
- 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";
23
23
  import { homedir } from "node:os";
24
- import { join, dirname } from "node:path";
24
+ import { join, dirname, resolve as pathResolve, sep as pathSep, basename, extname } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { validateProvider, detectProvider, checkExistingConfig, formatKeyBackupBox, acquireLock, releaseLock, getProviderConfig, localEncrypt as _localEncrypt, localDecrypt as _localDecrypt } from "./lib.js";
27
27
  import type { Provider } from "./lib.js";
@@ -68,20 +68,39 @@ if (command === "version" || args.includes("--version") || args.includes("-v"))
68
68
  // ── Help ────────────────────────────────────────────────────────────────────
69
69
 
70
70
  if (command === "help" || args.includes("--help") || args.includes("-h")) {
71
- console.log(`pairai v${VERSION}\n`);
71
+ console.log(`pairai v${VERSION} — connect AI agents to collaborate via the pairai hub\n`);
72
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");
73
+ console.log(' setup "Agent Name" [options] register agent and configure MCP server');
74
+ console.log(" serve [--provider ...] — start the MCP channel server (stdio)");
75
75
  console.log(" uninstall [--provider ...] [--delete-agent]");
76
+ console.log(" — remove MCP config, preserve credentials");
76
77
  console.log(" upgrade — update to latest version");
77
78
  console.log(" version — show version");
78
- console.log("\nProviders: claude, gemini, cursor, copilot, windsurf, codex, amazonq");
79
- console.log("\nEnvironment variables:");
79
+ console.log("\nSetup options:");
80
+ console.log(" --hub URL Hub URL (default: https://pairai.pro)");
81
+ console.log(" --provider NAME AI tool to configure (see list below)");
82
+ console.log(" --project Write MCP config to current project directory (default)");
83
+ console.log(" --user Write MCP config to user home directory (~/)");
84
+ console.log(" Makes pairai available in all projects without per-project setup");
85
+ console.log(" --force Overwrite existing config without prompting");
86
+ console.log("\nProviders:");
87
+ console.log(" claude Claude Code / Claude Desktop (.mcp.json or ~/.claude/settings.json)");
88
+ console.log(" gemini Gemini CLI (.gemini/ or ~/.gemini/settings.json)");
89
+ console.log(" cursor Cursor IDE (.cursor/ or ~/.cursor/mcp.json)");
90
+ console.log(" copilot GitHub Copilot (VS Code) (.vscode/mcp.json)");
91
+ console.log(" windsurf Windsurf IDE (~/.codeium/windsurf/ — user only)");
92
+ console.log(" codex Codex CLI (.codex/ or ~/.codex/config.toml)");
93
+ console.log(" amazonq Amazon Q Developer (.amazonq/ or ~/.aws/amazonq/)");
94
+ console.log("\nEnvironment variables (for serve command):");
80
95
  console.log(" PAIRAI_HUB_URL Hub URL (default: https://pairai.pro)");
81
96
  console.log(" PAIRAI_AGENT_CRED Agent API key");
82
97
  console.log(" PAIRAI_KEY_FILE Path to RSA private key .pem");
83
98
  console.log(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
84
99
  console.log(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
100
+ console.log("\nExamples:");
101
+ console.log(' npx pairai setup "My Assistant"');
102
+ console.log(' npx pairai setup "My Assistant" --provider claude --user');
103
+ console.log(" npx pairai uninstall --provider cursor --delete-agent");
85
104
  process.exit(0);
86
105
  }
87
106
 
@@ -153,18 +172,18 @@ if (command === "uninstall") {
153
172
  let removed = 0;
154
173
  let savedCredentials = false;
155
174
 
156
- // Collect both project-level and user/global-level config paths
175
+ // Collect both project-level and user-scoped config paths
157
176
  const scopes: Array<{ label: string; cfg: ReturnType<typeof getProviderConfig> }> = [];
158
177
  scopes.push({ label: "project", cfg: getProviderConfig(provider, cwd, home, false) });
159
- if (!getProviderConfig(provider, cwd, home, false).globalOnly) {
178
+ if (!getProviderConfig(provider, cwd, home, false).userOnly) {
160
179
  scopes.push({ label: "user", cfg: getProviderConfig(provider, cwd, home, true) });
161
180
  }
162
- // For claude, also check ~/.mcp.json (user-scope global config)
181
+ // For claude, also check legacy ~/.mcp.json (user-scope config from older versions)
163
182
  if (provider === "claude") {
164
183
  const userMcpJson = join(home, ".mcp.json");
165
184
  scopes.push({
166
185
  label: "user (~/.mcp.json)",
167
- cfg: { configPath: userMcpJson, mcpKey: "pairai-channel", format: "json" as const, globalOnly: true, instruction: "" },
186
+ cfg: { configPath: userMcpJson, mcpKey: "pairai-channel", format: "json" as const, userOnly: true, instruction: "" },
168
187
  });
169
188
  }
170
189
 
@@ -354,8 +373,12 @@ if (command === "setup") {
354
373
  process.exit(1);
355
374
  }
356
375
  }
357
- const globalIdx = rest.indexOf("--global");
358
- const useGlobal = globalIdx !== -1 ? (rest.splice(globalIdx, 1), true) : false;
376
+ // --user installs to user home directory; --project (default) installs to current project
377
+ // --global is accepted as a backward-compatible alias for --user
378
+ const userIdx = Math.max(rest.indexOf("--user"), rest.indexOf("--global"));
379
+ const useUser = userIdx !== -1 ? (rest.splice(userIdx, 1), true) : false;
380
+ const projectIdx = rest.indexOf("--project");
381
+ if (projectIdx !== -1) rest.splice(projectIdx, 1); // explicit default, just consume it
359
382
  let agentName = rest.find((a) => !a.startsWith("--"));
360
383
 
361
384
  const forceIdx = rest.indexOf("--force");
@@ -367,14 +390,14 @@ if (command === "setup") {
367
390
  validate: (v) => v.trim().length > 0 && v.trim().length <= 64 ? true : "Name must be 1-64 characters",
368
391
  });
369
392
  } else {
370
- console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
393
+ console.error('Usage: npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--user | --project] [--force]');
371
394
  process.exit(1);
372
395
  }
373
396
  }
374
397
 
375
398
  // Check for existing config to avoid accidental overwrites
376
399
  if (!useForce) {
377
- const existingConfigPath = checkExistingConfig(provider, process.cwd(), homedir(), useGlobal);
400
+ const existingConfigPath = checkExistingConfig(provider, process.cwd(), homedir(), useUser);
378
401
  if (existingConfigPath) {
379
402
  console.error(`\n pairai is already configured in ${existingConfigPath}`);
380
403
  console.error(` Running setup again would overwrite the existing API key and config.`);
@@ -417,7 +440,7 @@ if (command === "setup") {
417
440
  for (const line of formatKeyBackupBox(keyPath)) console.log(line);
418
441
  console.log();
419
442
 
420
- const cfg = getProviderConfig(provider, process.cwd(), homedir(), useGlobal);
443
+ const cfg = getProviderConfig(provider, process.cwd(), homedir(), useUser);
421
444
  const serverEntry = {
422
445
  command: "npx",
423
446
  args: ["pairai", "serve"],
@@ -463,8 +486,12 @@ if (command === "setup") {
463
486
  console.log(` 3. Share the code with another agent to connect`);
464
487
  if (provider === "claude") {
465
488
  console.log();
466
- console.log(` Optional: Enable real-time notifications (research preview):`);
467
- console.log(` claude --dangerously-load-development-channels`);
489
+ console.log(` Tips for Claude Code:`);
490
+ console.log(` Auto-allow all pairai tools — add to .claude/settings.local.json:`);
491
+ console.log(` { "permissions": { "allow": ["mcp__${cfg.mcpKey}__*"] } }`);
492
+ console.log();
493
+ console.log(` Enable real-time notifications (research preview):`);
494
+ console.log(` claude --dangerously-load-development-channels server:${cfg.mcpKey}`);
468
495
  }
469
496
 
470
497
  console.log();
@@ -476,7 +503,7 @@ if (command === "setup") {
476
503
  if (command !== "serve") {
477
504
  console.error(`pairai v${VERSION}\n`);
478
505
  console.error("Usage:");
479
- console.error(' npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--global] [--force]');
506
+ console.error(' npx pairai setup "Agent Name" [--hub URL] [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq] [--user | --project] [--force]');
480
507
  console.error(" npx pairai serve [--provider claude|gemini|cursor|copilot|windsurf|codex|amazonq]");
481
508
  console.error(" npx pairai uninstall [--provider ...] [--delete-agent] — remove MCP config, preserve keys");
482
509
  console.error(" npx pairai upgrade — update to latest version");
@@ -645,7 +672,7 @@ const instructions = [
645
672
  " - To connect: use pairai_connect_directly with the agent's ID (works instantly if they have autoAccept)",
646
673
  " - To collaborate: use pairai_create_task to send work, then pairai_reply to exchange messages",
647
674
  " - The full flow is: discover → connect → create task → exchange messages → complete",
648
- " - Featured agents on the hub: Reviewer (code/spec review), Artist (image generation), Polyglot (translation)",
675
+ " - Featured agents on the hub: use pairai_discover_agents to find specialist agents (code review, image generation, translation, and more)",
649
676
  "",
650
677
  "Notification attributes:",
651
678
  " task_id — the task this message belongs to",
@@ -690,20 +717,20 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
690
717
  type: "object" as const,
691
718
  properties: {
692
719
  task_id: { type: "string", description: "Task ID from the notification" },
693
- text: { type: "string", description: "Your message" },
720
+ message: { type: "string", description: "Your message" },
694
721
  content_type: { type: "string", enum: ["text", "json"], description: "Default: text. Use json for structured data." },
695
722
  },
696
- required: ["task_id", "text"],
723
+ required: ["task_id", "message"],
697
724
  },
698
725
  },
699
726
  {
700
727
  name: "pairai_update_status",
701
- description: "Update task status: working, input-required, completed, failed, cancelled.",
728
+ description: "Update task status: submitted (publish draft), working, input-required, completed, failed, cancelled.",
702
729
  inputSchema: {
703
730
  type: "object" as const,
704
731
  properties: {
705
732
  task_id: { type: "string" },
706
- status: { type: "string", enum: ["working", "input-required", "completed", "failed", "cancelled"] },
733
+ status: { type: "string", enum: ["submitted", "working", "input-required", "completed", "failed", "cancelled"] },
707
734
  },
708
735
  required: ["task_id", "status"],
709
736
  },
@@ -727,6 +754,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
727
754
  target_agent_id: { type: "string", description: "Agent ID to collaborate with" },
728
755
  title: { type: "string", description: "Short task title" },
729
756
  description: { type: "string", description: "What needs to be done" },
757
+ draft: { type: "boolean", description: "Create as draft (invisible to target until published via pairai_update_status with status 'submitted')" },
730
758
  },
731
759
  required: ["target_agent_id", "title"],
732
760
  },
@@ -896,6 +924,29 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
896
924
  required: ["task_id", "filename", "mime_type", "base64_content"],
897
925
  },
898
926
  },
927
+ {
928
+ name: "pairai_upload_file_from_path",
929
+ description:
930
+ "Upload a local file to a task by path (relative to project root). " +
931
+ "The file is read and encoded by the channel server — its content " +
932
+ "never passes through the LLM context window. " +
933
+ "Use this instead of pairai_upload_file for files on disk.",
934
+ inputSchema: {
935
+ type: "object" as const,
936
+ properties: {
937
+ task_id: { type: "string", description: "Task ID" },
938
+ file_path: {
939
+ type: "string",
940
+ description: "Path relative to project root, e.g. docs/specs/my-spec.md",
941
+ },
942
+ mime_type: {
943
+ type: "string",
944
+ description: "Override auto-detected MIME type (optional)",
945
+ },
946
+ },
947
+ required: ["task_id", "file_path"],
948
+ },
949
+ },
899
950
  {
900
951
  name: "pairai_download_file",
901
952
  description: "Download a file from a task. For encrypted tasks, the file is automatically decrypted.",
@@ -917,6 +968,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
917
968
  target_agent_id: { type: "string", description: "Agent ID to collaborate with" },
918
969
  title: { type: "string", description: "Task title (will be encrypted)" },
919
970
  description: { type: "string", description: "Task description (will be encrypted)" },
971
+ draft: { type: "boolean", description: "Create as draft (invisible to target until published)" },
920
972
  },
921
973
  required: ["target_agent_id", "title"],
922
974
  },
@@ -1014,57 +1066,78 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1014
1066
 
1015
1067
  if (name === "pairai_check_updates") {
1016
1068
  await loadPublicKeys();
1017
- const updates = (await hubGet("/updates")) as {
1018
- hasUpdates: boolean;
1019
- pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
1020
- unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
1069
+ const updates = (await hubGet("/events")) as {
1070
+ events: Array<{
1071
+ id: number;
1072
+ type: string;
1073
+ taskId: string | null;
1074
+ fromAgentId: string | null;
1075
+ data: Record<string, unknown>;
1076
+ createdAt: string;
1077
+ }>;
1021
1078
  cursor: number;
1079
+ hasMore: boolean;
1022
1080
  };
1023
1081
 
1024
- if (!updates.hasUpdates) {
1082
+ if (updates.events.length === 0) {
1025
1083
  return { content: [{ type: "text" as const, text: "No updates. You're all caught up." }] };
1026
1084
  }
1027
1085
 
1028
1086
  const parts: string[] = [];
1029
1087
 
1030
- if (updates.pendingTasks.length > 0) {
1088
+ const taskEvents = updates.events.filter(e => e.type === "task.created" || e.type === "task.approval_required");
1089
+ if (taskEvents.length > 0) {
1031
1090
  const enriched: string[] = [];
1032
- for (const task of updates.pendingTasks) {
1033
- const full = (await hubGet(`/tasks/${task.id}`)) as any;
1034
- const desc = full.encrypted ? decryptTaskDescription(full, task.id) : (full.description ?? "");
1035
- const title = desc.split("\n")[0] || task.title;
1036
- enriched.push(`- "${title}" from ${task.fromAgent} (task ID: ${task.id})`);
1091
+ for (const event of taskEvents) {
1092
+ if (!event.taskId) continue;
1093
+ const full = (await hubGet(`/tasks/${event.taskId}`)) as any;
1094
+ const desc = full.encrypted ? decryptTaskDescription(full, event.taskId) : (full.description ?? "");
1095
+ const title = desc.split("\n")[0] || full.title || "Untitled";
1096
+ const fromAgent = (event.data.fromAgentName as string) ?? event.fromAgentId ?? "unknown";
1097
+ enriched.push(`- "${title}" from ${fromAgent} (task ID: ${event.taskId})${event.type === "task.approval_required" ? " [APPROVAL REQUIRED]" : ""}`);
1037
1098
  }
1038
- parts.push(`**${updates.pendingTasks.length} pending task(s):**\n${enriched.join("\n")}`);
1099
+ parts.push(`**${taskEvents.length} pending task(s):**\n${enriched.join("\n")}`);
1039
1100
  }
1040
1101
 
1041
- if (updates.unreadMessages.length > 0) {
1102
+ const msgEvents = updates.events.filter(e => e.type === "message.created");
1103
+ if (msgEvents.length > 0) {
1104
+ // Group by taskId for summary
1105
+ const byTask = new Map<string, typeof msgEvents>();
1106
+ for (const event of msgEvents) {
1107
+ if (!event.taskId) continue;
1108
+ const list = byTask.get(event.taskId) ?? [];
1109
+ list.push(event);
1110
+ byTask.set(event.taskId, list);
1111
+ }
1042
1112
  const enriched: string[] = [];
1043
- for (const unread of updates.unreadMessages) {
1044
- const full = (await hubGet(`/tasks/${unread.taskId}`)) as any;
1045
- const msgs = (full.messages ?? []) as Array<any>;
1046
- const recent = msgs.slice(-unread.count);
1113
+ for (const [taskId, events] of byTask) {
1114
+ const full = (await hubGet(`/tasks/${taskId}`)) as any;
1115
+ const taskTitle = full.title ?? "Untitled";
1116
+ const msgs = (await hubGet(`/tasks/${taskId}/messages`)) as Array<any>;
1047
1117
  const previews: string[] = [];
1048
- for (const m of recent) {
1049
- const d = full.encrypted ? decryptMessage(m, unread.taskId) : { content: m.content, contentType: m.contentType };
1050
- previews.push(d.content.slice(0, 100));
1118
+ for (const event of events) {
1119
+ const messageId = event.data.messageId as string | undefined;
1120
+ const msg = messageId ? msgs.find((m: any) => m.id === messageId) : msgs[msgs.length - 1];
1121
+ if (msg) {
1122
+ const d = full.encrypted ? decryptMessage(msg, taskId) : { content: msg.content, contentType: msg.contentType };
1123
+ previews.push(d.content.slice(0, 100));
1124
+ }
1051
1125
  }
1052
- enriched.push(`- ${unread.count} new in "${unread.taskTitle}" (task ID: ${unread.taskId})\n Preview: ${previews.join(" | ")}`);
1126
+ enriched.push(`- ${events.length} new in "${taskTitle}" (task ID: ${taskId})\n Preview: ${previews.join(" | ")}`);
1053
1127
  }
1054
1128
  parts.push(`**Unread messages:**\n${enriched.join("\n")}`);
1055
1129
  }
1056
1130
 
1057
- // Always ackthis is the authoritative "user has seen these" signal.
1058
- // The poll loop does NOT ack; only this tool does.
1131
+ // Ack (idempotentpoll loop also acks after delivery).
1059
1132
  if (updates.cursor > 0) {
1060
- await hubPost("/updates/ack", { cursor: updates.cursor });
1133
+ await hubPost("/events/ack", { cursor: updates.cursor });
1061
1134
  }
1062
1135
 
1063
1136
  return { content: [{ type: "text" as const, text: parts.join("\n\n") }] };
1064
1137
  }
1065
1138
 
1066
1139
  if (name === "pairai_reply") {
1067
- const { task_id, text, content_type } = args as { task_id: string; text: string; content_type?: string };
1140
+ const { task_id, message: text, content_type } = args as { task_id: string; message: string; content_type?: string };
1068
1141
 
1069
1142
  // Check if task is encrypted
1070
1143
  const taskData = (await hubGet(`/tasks/${task_id}`)) as any;
@@ -1123,8 +1196,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1123
1196
  }
1124
1197
 
1125
1198
  if (name === "pairai_create_task") {
1126
- const { target_agent_id, title, description } = args as {
1127
- target_agent_id: string; title: string; description?: string;
1199
+ const { target_agent_id, title, description, draft } = args as {
1200
+ target_agent_id: string; title: string; description?: string; draft?: boolean;
1128
1201
  };
1129
1202
 
1130
1203
  // Auto-encrypt when both agents have keys and we have a private key
@@ -1146,8 +1219,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1146
1219
  encrypted: true,
1147
1220
  descriptionKeys: encryptedKeys,
1148
1221
  senderSignature: signature,
1222
+ ...(draft ? { draft: true } : {}),
1149
1223
  });
1150
- return { content: [{ type: "text" as const, text: `Task created (encrypted). ID: ${taskId}` }] };
1224
+ const statusMsg = draft ? "draft" : "submitted";
1225
+ return { content: [{ type: "text" as const, text: `Task created (encrypted, ${statusMsg}). ID: ${taskId}${draft ? "\nDraft — use pairai_update_status with status 'submitted' to publish." : ""}` }] };
1151
1226
  }
1152
1227
 
1153
1228
  // Fallback: plaintext (no keys available)
@@ -1155,6 +1230,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1155
1230
  targetAgentId: target_agent_id,
1156
1231
  title,
1157
1232
  description,
1233
+ ...(draft ? { draft: true } : {}),
1158
1234
  });
1159
1235
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
1160
1236
  }
@@ -1302,6 +1378,77 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1302
1378
  return { content: [{ type: "text" as const, text: JSON.stringify({ ...data, messages: decryptedMsgs }, null, 2) }] };
1303
1379
  }
1304
1380
 
1381
+ if (name === "pairai_upload_file_from_path") {
1382
+ const { task_id, file_path, mime_type } = args as {
1383
+ task_id: string; file_path: string; mime_type?: string;
1384
+ };
1385
+
1386
+ // 1. Path containment check
1387
+ const safeCwd = pathResolve(process.cwd());
1388
+ const resolved = pathResolve(safeCwd, file_path);
1389
+ if (!resolved.startsWith(safeCwd + pathSep) && resolved !== safeCwd) {
1390
+ return { content: [{ type: "text" as const, text: "Error: file not found or not accessible." }] };
1391
+ }
1392
+
1393
+ // 2. Open with O_NOFOLLOW to reject symlinks (TOCTOU-safe)
1394
+ let fd: number;
1395
+ try {
1396
+ fd = openSync(resolved, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
1397
+ } catch {
1398
+ return { content: [{ type: "text" as const, text: "Error: file not found or not accessible." }] };
1399
+ }
1400
+
1401
+ try {
1402
+ const stat = fstatSync(fd);
1403
+ if (!stat.isFile()) {
1404
+ return { content: [{ type: "text" as const, text: "Error: path is not a regular file." }] };
1405
+ }
1406
+ if (stat.size > 50 * 1024 * 1024) {
1407
+ return { content: [{ type: "text" as const, text: "Error: file exceeds 50 MB limit." }] };
1408
+ }
1409
+
1410
+ // 3. Read and encode from fd
1411
+ const fileBuffer = readFileSync(fd);
1412
+ const base64Content = fileBuffer.toString("base64");
1413
+ const filename = basename(resolved);
1414
+
1415
+ // 4. Auto-detect MIME type
1416
+ const ext = extname(filename).toLowerCase();
1417
+ const detectedMime = mime_type || MIME_MAP[ext] || "application/octet-stream";
1418
+
1419
+ // 5. Delegate to existing upload logic (encrypted or plain)
1420
+ const taskData = (await hubGet(`/tasks/${task_id}`)) as any;
1421
+ if (taskData.encrypted) {
1422
+ if (fileBuffer.byteLength > 28 * 1024 * 1024) {
1423
+ return { content: [{ type: "text" as const, text: "Error: File too large for encrypted upload (max ~28 MB)." }] };
1424
+ }
1425
+ await loadPublicKeys();
1426
+ const otherId = taskData.initiatorAgentId === myAgentId
1427
+ ? taskData.targetAgentId : taskData.initiatorAgentId;
1428
+ const otherPub = pubKeyCache.get(otherId);
1429
+ if (!otherPub || !myPublicKey || !PRIVATE_KEY) {
1430
+ return { content: [{ type: "text" as const, text: "Error: Missing cryptographic keys for encrypted upload." }] };
1431
+ }
1432
+ const envelope = JSON.stringify({ filename, mimeType: detectedMime, data: base64Content });
1433
+ const { ciphertext, signature, encryptedKeys } = localEncrypt(envelope, task_id, {
1434
+ [myAgentId]: myPublicKey, [otherId]: otherPub,
1435
+ });
1436
+ const data = await hubPost(`/tasks/${task_id}/files/json`, {
1437
+ filename: "encrypted_file", mimeType: "application/octet-stream",
1438
+ base64Content: ciphertext, encryptedKeys, senderSignature: signature,
1439
+ });
1440
+ return { content: [{ type: "text" as const, text: `Uploaded ${filename} (encrypted). ${JSON.stringify(data)}` }] };
1441
+ }
1442
+
1443
+ const data = await hubPost(`/tasks/${task_id}/files/json`, {
1444
+ filename, mimeType: detectedMime, base64Content,
1445
+ });
1446
+ return { content: [{ type: "text" as const, text: `Uploaded ${filename}. ${JSON.stringify(data)}` }] };
1447
+ } finally {
1448
+ closeSync(fd);
1449
+ }
1450
+ }
1451
+
1305
1452
  if (name === "pairai_upload_file") {
1306
1453
  const { task_id, filename, mime_type, base64_content } = args as {
1307
1454
  task_id: string; filename: string; mime_type: string; base64_content: string;
@@ -1454,10 +1601,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1454
1601
  if (name === "pairai_create_encrypted_task") {
1455
1602
  if (!PRIVATE_KEY)
1456
1603
  return { content: [{ type: "text" as const, text: "No private key configured. Re-run setup." }] };
1457
- const { target_agent_id, title, description } = args as {
1604
+ const { target_agent_id, title, description, draft } = args as {
1458
1605
  target_agent_id: string;
1459
1606
  title: string;
1460
1607
  description?: string;
1608
+ draft?: boolean;
1461
1609
  };
1462
1610
  // Refresh keys in case a new connection was established
1463
1611
  await loadPublicKeys();
@@ -1483,8 +1631,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1483
1631
  encrypted: true,
1484
1632
  descriptionKeys: encryptedKeys,
1485
1633
  senderSignature: signature,
1634
+ ...(draft ? { draft: true } : {}),
1486
1635
  });
1487
- return { content: [{ type: "text" as const, text: `Encrypted task created. ID: ${taskId}` }] };
1636
+ const statusMsg = draft ? "draft" : "submitted";
1637
+ return { content: [{ type: "text" as const, text: `Encrypted task created (${statusMsg}). ID: ${taskId}${draft ? "\nDraft — use pairai_update_status with status 'submitted' to publish." : ""}` }] };
1488
1638
  }
1489
1639
 
1490
1640
  if (name === "pairai_delete_message") {
@@ -1570,8 +1720,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1570
1720
 
1571
1721
  // ── Polling ──────────────────────────────────────────────────────────────────
1572
1722
 
1573
- const seenMessages = new Set<string>();
1574
- const SEEN_MESSAGES_MAX = 10_000;
1723
+ const MIME_MAP: Record<string, string> = {
1724
+ ".md": "text/markdown", ".txt": "text/plain", ".json": "application/json",
1725
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
1726
+ ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
1727
+ ".pdf": "application/pdf", ".html": "text/html", ".csv": "text/csv",
1728
+ ".yaml": "text/yaml", ".yml": "text/yaml",
1729
+ ".ts": "text/plain", ".js": "text/plain",
1730
+ };
1575
1731
 
1576
1732
  function decryptMessage(
1577
1733
  msg: { content: string; contentType: string; senderAgentId: string; encryptedKeys?: any; senderSignature?: string },
@@ -1625,140 +1781,163 @@ function decryptTaskDescription(
1625
1781
  return full.description ?? "";
1626
1782
  }
1627
1783
 
1628
- async function poll() {
1629
- try {
1630
- // Refresh public keys to pick up new connections
1631
- await loadPublicKeys();
1632
-
1633
- const updates = (await hubGet("/updates")) as {
1634
- hasUpdates: boolean;
1635
- pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
1636
- unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
1637
- cursor: number;
1784
+ async function deliverEventNotification(event: {
1785
+ id: number;
1786
+ type: string;
1787
+ taskId: string | null;
1788
+ fromAgentId: string | null;
1789
+ data: Record<string, unknown>;
1790
+ createdAt: string;
1791
+ }) {
1792
+ const fromAgent = (event.data.fromAgentName as string) ?? event.fromAgentId ?? "unknown";
1793
+
1794
+ if (event.type === "task.created" || event.type === "task.approval_required") {
1795
+ if (!event.taskId) return;
1796
+
1797
+ const full = (await hubGet(`/tasks/${event.taskId}`)) as {
1798
+ title?: string;
1799
+ description?: string;
1800
+ encrypted?: boolean;
1801
+ descriptionKeys?: any;
1802
+ senderSignature?: string;
1803
+ initiatorAgentId?: string;
1804
+ approvalStatus?: string | null;
1638
1805
  };
1806
+ const taskMsgs = (await hubGet(`/tasks/${event.taskId}/messages`)) as Array<{
1807
+ content: string;
1808
+ contentType: string;
1809
+ senderAgentId: string;
1810
+ encryptedKeys?: any;
1811
+ senderSignature?: string;
1812
+ }>;
1639
1813
 
1640
- debugLog(`poll: hasUpdates=${updates.hasUpdates} tasks=${updates.pendingTasks.length} messages=${updates.unreadMessages.length} cursor=${updates.cursor}`);
1641
- if (!updates.hasUpdates) return;
1642
- console.error(`[pairai] poll: ${updates.pendingTasks.length} tasks, ${updates.unreadMessages.length} messages`);
1643
-
1644
- for (const task of updates.pendingTasks) {
1645
- const key = `task:${task.id}`;
1646
- if (seenMessages.has(key)) { debugLog(`skip seen task ${task.id}`); continue; }
1647
- seenMessages.add(key);
1648
-
1649
- const full = (await hubGet(`/tasks/${task.id}`)) as {
1650
- description?: string;
1651
- encrypted?: boolean;
1652
- descriptionKeys?: any;
1653
- senderSignature?: string;
1654
- initiatorAgentId?: string;
1655
- approvalStatus?: string | null;
1656
- };
1657
- const taskMsgs = (await hubGet(`/tasks/${task.id}/messages`)) as Array<{
1658
- content: string;
1659
- contentType: string;
1660
- senderAgentId: string;
1661
- encryptedKeys?: any;
1662
- senderSignature?: string;
1663
- }>;
1814
+ const desc = decryptTaskDescription(full, event.taskId);
1815
+ const taskTitle = desc.split("\n")[0] || full.title || "Untitled";
1816
+ const decryptedMessages = (taskMsgs ?? []).map((m) => {
1817
+ // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1818
+ if (m.contentType === "encrypted" && m.encryptedKeys && m.content.length < 30) {
1819
+ return `[File attachment — use pairai_download_file with task_id: "${event.taskId}", file_id: "${m.content}"]`;
1820
+ }
1821
+ try {
1822
+ const d = decryptMessage(m, event.taskId!);
1823
+ return d.content;
1824
+ } catch {
1825
+ return "[decryption failed]";
1826
+ }
1827
+ });
1664
1828
 
1665
- const desc = decryptTaskDescription(full, task.id);
1666
- const decryptedMessages = (taskMsgs ?? []).map((m) => {
1667
- // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1668
- if (m.contentType === "encrypted" && m.encryptedKeys && m.content.length < 30) {
1669
- return `[File attachment — use pairai_download_file with task_id: "${task.id}", file_id: "${m.content}"]`;
1670
- }
1671
- try {
1672
- const d = decryptMessage(m, task.id);
1673
- return d.content;
1674
- } catch {
1675
- return "[decryption failed]";
1676
- }
1677
- });
1829
+ const isPendingApproval = full.approvalStatus === "pending" || event.type === "task.approval_required";
1830
+ const approvalPrefix = isPendingApproval ? "[APPROVAL REQUIRED] " : "";
1831
+ const approvalSuffix = isPendingApproval
1832
+ ? `\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}`
1833
+ : "";
1678
1834
 
1679
- const isPendingApproval = full.approvalStatus === "pending";
1680
- const approvalPrefix = isPendingApproval ? "[APPROVAL REQUIRED] " : "";
1681
- const approvalSuffix = isPendingApproval
1682
- ? `\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}`
1683
- : "";
1835
+ const body = approvalPrefix + [desc || taskTitle, ...decryptedMessages.map((c) => `> ${c}`)].join("\n\n") + approvalSuffix;
1684
1836
 
1685
- const body = approvalPrefix + [desc || task.title, ...decryptedMessages.map((c) => `> ${c}`)].join("\n\n") + approvalSuffix;
1837
+ await mcp.notification({
1838
+ method: "notifications/claude/channel",
1839
+ params: {
1840
+ content: body,
1841
+ meta: { task_id: event.taskId, task_title: taskTitle, from_agent: fromAgent, event_type: "new_task" },
1842
+ },
1843
+ });
1844
+ console.error(`[pairai] channel notification sent: new_task ${event.taskId} from ${fromAgent}${isPendingApproval ? " [APPROVAL REQUIRED]" : ""}`);
1845
+
1846
+ } else if (event.type === "message.created") {
1847
+ if (!event.taskId) return;
1848
+
1849
+ const msgs = (await hubGet(`/tasks/${event.taskId}/messages`)) as Array<{
1850
+ id: string;
1851
+ content: string;
1852
+ contentType: string;
1853
+ senderAgentId: string;
1854
+ encryptedKeys?: any;
1855
+ senderSignature?: string;
1856
+ }>;
1857
+ if (!msgs || msgs.length === 0) return;
1858
+
1859
+ const messageId = event.data.messageId as string | undefined;
1860
+ const msg = messageId ? msgs.find((m) => m.id === messageId) : msgs[msgs.length - 1];
1861
+ if (!msg) return;
1686
1862
 
1863
+ // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1864
+ const isEncryptedFile = msg.contentType === "encrypted" && msg.encryptedKeys && msg.content.length < 30;
1865
+ let decrypted: { content: string; contentType: string };
1866
+ if (isEncryptedFile) {
1867
+ decrypted = { content: `[File attachment — use pairai_download_file with task_id: "${event.taskId}", file_id: "${msg.content}"]`, contentType: "text" };
1868
+ } else {
1687
1869
  try {
1688
- await mcp.notification({
1689
- method: "notifications/claude/channel",
1690
- params: {
1691
- content: body,
1692
- meta: { task_id: task.id, task_title: task.title, from_agent: task.fromAgent, event_type: "new_task" },
1693
- },
1694
- });
1695
- console.error(`[pairai] channel notification sent: new_task ${task.id} from ${task.fromAgent}${isPendingApproval ? " [APPROVAL REQUIRED]" : ""}`);
1696
- } catch (err) {
1697
- console.error(`[pairai] channel notification FAILED: ${(err as Error).message}`);
1870
+ decrypted = decryptMessage(msg, event.taskId);
1871
+ } catch {
1872
+ decrypted = { content: "[decryption failed]", contentType: "text" };
1698
1873
  }
1699
1874
  }
1700
1875
 
1701
- for (const unread of updates.unreadMessages) {
1702
- const msgs = (await hubGet(`/tasks/${unread.taskId}/messages`)) as Array<{
1703
- id: string;
1704
- content: string;
1705
- contentType: string;
1706
- senderAgentId: string;
1707
- encryptedKeys?: any;
1708
- senderSignature?: string;
1876
+ const full = (await hubGet(`/tasks/${event.taskId}`)) as { title?: string };
1877
+ const taskTitle = full.title ?? "Untitled";
1878
+
1879
+ await mcp.notification({
1880
+ method: "notifications/claude/channel",
1881
+ params: {
1882
+ content: decrypted.content,
1883
+ meta: {
1884
+ task_id: event.taskId,
1885
+ task_title: taskTitle,
1886
+ from_agent: fromAgent,
1887
+ event_type: "new_message",
1888
+ content_type: decrypted.contentType,
1889
+ },
1890
+ },
1891
+ });
1892
+ console.error(`[pairai] channel notification sent: new_message in ${event.taskId}`);
1893
+
1894
+ } else {
1895
+ debugLog(`poll: skipping event type=${event.type} id=${event.id}`);
1896
+ }
1897
+ }
1898
+
1899
+ async function poll() {
1900
+ try {
1901
+ // Refresh public keys to pick up new connections
1902
+ await loadPublicKeys();
1903
+
1904
+ const updates = (await hubGet("/events")) as {
1905
+ events: Array<{
1906
+ id: number;
1907
+ type: string;
1908
+ taskId: string | null;
1909
+ fromAgentId: string | null;
1910
+ data: Record<string, unknown>;
1911
+ createdAt: string;
1709
1912
  }>;
1913
+ cursor: number;
1914
+ hasMore: boolean;
1915
+ };
1710
1916
 
1711
- debugLog(`unread: taskId=${unread.taskId} count=${unread.count} fetched=${msgs?.length ?? 0}`);
1712
- if (!msgs || msgs.length === 0) continue;
1713
- for (const msg of msgs.slice(-unread.count)) {
1714
- const key = `msg:${msg.id}`;
1715
- if (seenMessages.has(key)) { debugLog(`skip seen msg ${msg.id}`); continue; }
1716
- seenMessages.add(key);
1917
+ debugLog(`poll: ${updates.events.length} events, cursor=${updates.cursor}, hasMore=${updates.hasMore}`);
1717
1918
 
1718
- // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1719
- const isEncryptedFile = msg.contentType === "encrypted" && msg.encryptedKeys && msg.content.length < 30;
1720
- let decrypted: { content: string; contentType: string };
1721
- if (isEncryptedFile) {
1722
- decrypted = { content: `[File attachment — use pairai_download_file with task_id: "${unread.taskId}", file_id: "${msg.content}"]`, contentType: "text" };
1723
- } else {
1724
- try {
1725
- decrypted = decryptMessage(msg, unread.taskId);
1726
- } catch {
1727
- decrypted = { content: "[decryption failed]", contentType: "text" };
1728
- }
1729
- }
1919
+ if (updates.events.length === 0) return;
1730
1920
 
1731
- try {
1732
- await mcp.notification({
1733
- method: "notifications/claude/channel",
1734
- params: {
1735
- content: decrypted.content,
1736
- meta: {
1737
- task_id: unread.taskId,
1738
- task_title: unread.taskTitle,
1739
- from_agent: msg.senderAgentId,
1740
- event_type: "new_message",
1741
- content_type: decrypted.contentType,
1742
- },
1743
- },
1744
- });
1745
- console.error(`[pairai] channel notification sent: new_message in ${unread.taskId}`);
1746
- } catch (err) {
1747
- console.error(`[pairai] channel notification FAILED: ${(err as Error).message}`);
1748
- }
1921
+ for (const event of updates.events) {
1922
+ try {
1923
+ await deliverEventNotification(event);
1924
+ } catch (err) {
1925
+ console.error(`[pairai] notification delivery failed for event ${event.id}: ${(err as Error).message}`);
1749
1926
  }
1750
1927
  }
1751
1928
 
1752
- // Do NOT ack the hub here — the hub's lastSeenRowid is only advanced
1753
- // when the user explicitly calls pairai_check_updates (authoritative ack).
1754
- // The seenMessages Set prevents duplicate notifications within this session.
1755
- debugLog(`poll: processed cursor=${updates.cursor} (hub NOT acked — seenMessages dedup only)`);
1929
+ // Ack after successful delivery
1930
+ if (updates.cursor > 0) {
1931
+ try {
1932
+ await hubPost("/events/ack", { cursor: updates.cursor });
1933
+ debugLog(`poll: acked cursor=${updates.cursor}`);
1934
+ } catch (err) {
1935
+ debugLog(`poll: ack failed (will retry next cycle): ${(err as Error).message}`);
1936
+ }
1937
+ }
1756
1938
 
1757
- // Prevent unbounded memory growth
1758
- if (seenMessages.size > SEEN_MESSAGES_MAX) {
1759
- const excess = seenMessages.size - SEEN_MESSAGES_MAX;
1760
- const iter = seenMessages.values();
1761
- for (let i = 0; i < excess; i++) seenMessages.delete(iter.next().value!);
1939
+ if (updates.hasMore) {
1940
+ setImmediate(poll);
1762
1941
  }
1763
1942
  } catch (err) {
1764
1943
  console.error(`[pairai] poll error: ${(err as Error).message}`);