pairai 0.5.0 → 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 (3) hide show
  1. package/lib.ts +31 -23
  2. package/package.json +2 -1
  3. package/pairai.ts +221 -159
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.5.0",
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)
@@ -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");
@@ -1039,49 +1066,71 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1039
1066
 
1040
1067
  if (name === "pairai_check_updates") {
1041
1068
  await loadPublicKeys();
1042
- const updates = (await hubGet("/updates")) as {
1043
- hasUpdates: boolean;
1044
- pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
1045
- 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
+ }>;
1046
1078
  cursor: number;
1079
+ hasMore: boolean;
1047
1080
  };
1048
1081
 
1049
- if (!updates.hasUpdates) {
1082
+ if (updates.events.length === 0) {
1050
1083
  return { content: [{ type: "text" as const, text: "No updates. You're all caught up." }] };
1051
1084
  }
1052
1085
 
1053
1086
  const parts: string[] = [];
1054
1087
 
1055
- 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) {
1056
1090
  const enriched: string[] = [];
1057
- for (const task of updates.pendingTasks) {
1058
- const full = (await hubGet(`/tasks/${task.id}`)) as any;
1059
- const desc = full.encrypted ? decryptTaskDescription(full, task.id) : (full.description ?? "");
1060
- const title = desc.split("\n")[0] || task.title;
1061
- 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]" : ""}`);
1062
1098
  }
1063
- parts.push(`**${updates.pendingTasks.length} pending task(s):**\n${enriched.join("\n")}`);
1099
+ parts.push(`**${taskEvents.length} pending task(s):**\n${enriched.join("\n")}`);
1064
1100
  }
1065
1101
 
1066
- 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
+ }
1067
1112
  const enriched: string[] = [];
1068
- for (const unread of updates.unreadMessages) {
1069
- const full = (await hubGet(`/tasks/${unread.taskId}`)) as any;
1070
- const msgs = (full.messages ?? []) as Array<any>;
1071
- 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>;
1072
1117
  const previews: string[] = [];
1073
- for (const m of recent) {
1074
- const d = full.encrypted ? decryptMessage(m, unread.taskId) : { content: m.content, contentType: m.contentType };
1075
- 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
+ }
1076
1125
  }
1077
- 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(" | ")}`);
1078
1127
  }
1079
1128
  parts.push(`**Unread messages:**\n${enriched.join("\n")}`);
1080
1129
  }
1081
1130
 
1082
1131
  // Ack (idempotent — poll loop also acks after delivery).
1083
1132
  if (updates.cursor > 0) {
1084
- await hubPost("/updates/ack", { cursor: updates.cursor });
1133
+ await hubPost("/events/ack", { cursor: updates.cursor });
1085
1134
  }
1086
1135
 
1087
1136
  return { content: [{ type: "text" as const, text: parts.join("\n\n") }] };
@@ -1671,9 +1720,6 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1671
1720
 
1672
1721
  // ── Polling ──────────────────────────────────────────────────────────────────
1673
1722
 
1674
- const seenMessages = new Set<string>();
1675
- const SEEN_MESSAGES_MAX = 10_000;
1676
-
1677
1723
  const MIME_MAP: Record<string, string> = {
1678
1724
  ".md": "text/markdown", ".txt": "text/plain", ".json": "application/json",
1679
1725
  ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
@@ -1735,147 +1781,163 @@ function decryptTaskDescription(
1735
1781
  return full.description ?? "";
1736
1782
  }
1737
1783
 
1738
- async function poll() {
1739
- try {
1740
- // Refresh public keys to pick up new connections
1741
- await loadPublicKeys();
1742
-
1743
- const updates = (await hubGet("/updates")) as {
1744
- hasUpdates: boolean;
1745
- pendingTasks: Array<{ id: string; title: string; fromAgent: string }>;
1746
- unreadMessages: Array<{ taskId: string; taskTitle: string; count: number }>;
1747
- 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;
1748
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
+ }>;
1749
1813
 
1750
- debugLog(`poll: hasUpdates=${updates.hasUpdates} tasks=${updates.pendingTasks.length} messages=${updates.unreadMessages.length} cursor=${updates.cursor}`);
1751
- if (!updates.hasUpdates) return;
1752
- console.error(`[pairai] poll: ${updates.pendingTasks.length} tasks, ${updates.unreadMessages.length} messages`);
1753
-
1754
- for (const task of updates.pendingTasks) {
1755
- const key = `task:${task.id}`;
1756
- if (seenMessages.has(key)) { debugLog(`skip seen task ${task.id}`); continue; }
1757
- seenMessages.add(key);
1758
-
1759
- const full = (await hubGet(`/tasks/${task.id}`)) as {
1760
- description?: string;
1761
- encrypted?: boolean;
1762
- descriptionKeys?: any;
1763
- senderSignature?: string;
1764
- initiatorAgentId?: string;
1765
- approvalStatus?: string | null;
1766
- };
1767
- const taskMsgs = (await hubGet(`/tasks/${task.id}/messages`)) as Array<{
1768
- content: string;
1769
- contentType: string;
1770
- senderAgentId: string;
1771
- encryptedKeys?: any;
1772
- senderSignature?: string;
1773
- }>;
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
+ });
1774
1828
 
1775
- const desc = decryptTaskDescription(full, task.id);
1776
- const decryptedMessages = (taskMsgs ?? []).map((m) => {
1777
- // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1778
- if (m.contentType === "encrypted" && m.encryptedKeys && m.content.length < 30) {
1779
- return `[File attachment — use pairai_download_file with task_id: "${task.id}", file_id: "${m.content}"]`;
1780
- }
1781
- try {
1782
- const d = decryptMessage(m, task.id);
1783
- return d.content;
1784
- } catch {
1785
- return "[decryption failed]";
1786
- }
1787
- });
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
+ : "";
1834
+
1835
+ const body = approvalPrefix + [desc || taskTitle, ...decryptedMessages.map((c) => `> ${c}`)].join("\n\n") + approvalSuffix;
1788
1836
 
1789
- const isPendingApproval = full.approvalStatus === "pending";
1790
- const approvalPrefix = isPendingApproval ? "[APPROVAL REQUIRED] " : "";
1791
- const approvalSuffix = isPendingApproval
1792
- ? `\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}`
1793
- : "";
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;
1794
1858
 
1795
- const body = approvalPrefix + [desc || task.title, ...decryptedMessages.map((c) => `> ${c}`)].join("\n\n") + approvalSuffix;
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;
1796
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 {
1797
1869
  try {
1798
- await mcp.notification({
1799
- method: "notifications/claude/channel",
1800
- params: {
1801
- content: body,
1802
- meta: { task_id: task.id, task_title: task.title, from_agent: task.fromAgent, event_type: "new_task" },
1803
- },
1804
- });
1805
- console.error(`[pairai] channel notification sent: new_task ${task.id} from ${task.fromAgent}${isPendingApproval ? " [APPROVAL REQUIRED]" : ""}`);
1806
- } catch (err) {
1807
- console.error(`[pairai] channel notification FAILED: ${(err as Error).message}`);
1870
+ decrypted = decryptMessage(msg, event.taskId);
1871
+ } catch {
1872
+ decrypted = { content: "[decryption failed]", contentType: "text" };
1808
1873
  }
1809
1874
  }
1810
1875
 
1811
- for (const unread of updates.unreadMessages) {
1812
- const msgs = (await hubGet(`/tasks/${unread.taskId}/messages`)) as Array<{
1813
- id: string;
1814
- content: string;
1815
- contentType: string;
1816
- senderAgentId: string;
1817
- encryptedKeys?: any;
1818
- 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;
1819
1912
  }>;
1913
+ cursor: number;
1914
+ hasMore: boolean;
1915
+ };
1820
1916
 
1821
- debugLog(`unread: taskId=${unread.taskId} count=${unread.count} fetched=${msgs?.length ?? 0}`);
1822
- if (!msgs || msgs.length === 0) continue;
1823
- for (const msg of msgs.slice(-unread.count)) {
1824
- const key = `msg:${msg.id}`;
1825
- if (seenMessages.has(key)) { debugLog(`skip seen msg ${msg.id}`); continue; }
1826
- seenMessages.add(key);
1917
+ debugLog(`poll: ${updates.events.length} events, cursor=${updates.cursor}, hasMore=${updates.hasMore}`);
1827
1918
 
1828
- // Encrypted file messages: content is a file ID (short nanoid), not ciphertext
1829
- const isEncryptedFile = msg.contentType === "encrypted" && msg.encryptedKeys && msg.content.length < 30;
1830
- let decrypted: { content: string; contentType: string };
1831
- if (isEncryptedFile) {
1832
- decrypted = { content: `[File attachment — use pairai_download_file with task_id: "${unread.taskId}", file_id: "${msg.content}"]`, contentType: "text" };
1833
- } else {
1834
- try {
1835
- decrypted = decryptMessage(msg, unread.taskId);
1836
- } catch {
1837
- decrypted = { content: "[decryption failed]", contentType: "text" };
1838
- }
1839
- }
1919
+ if (updates.events.length === 0) return;
1840
1920
 
1841
- try {
1842
- await mcp.notification({
1843
- method: "notifications/claude/channel",
1844
- params: {
1845
- content: decrypted.content,
1846
- meta: {
1847
- task_id: unread.taskId,
1848
- task_title: unread.taskTitle,
1849
- from_agent: msg.senderAgentId,
1850
- event_type: "new_message",
1851
- content_type: decrypted.contentType,
1852
- },
1853
- },
1854
- });
1855
- console.error(`[pairai] channel notification sent: new_message in ${unread.taskId}`);
1856
- } catch (err) {
1857
- console.error(`[pairai] channel notification FAILED: ${(err as Error).message}`);
1858
- }
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}`);
1859
1926
  }
1860
1927
  }
1861
1928
 
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
1929
+ // Ack after successful delivery
1865
1930
  if (updates.cursor > 0) {
1866
1931
  try {
1867
- await hubPost("/updates/ack", { cursor: updates.cursor });
1932
+ await hubPost("/events/ack", { cursor: updates.cursor });
1868
1933
  debugLog(`poll: acked cursor=${updates.cursor}`);
1869
1934
  } catch (err) {
1870
1935
  debugLog(`poll: ack failed (will retry next cycle): ${(err as Error).message}`);
1871
1936
  }
1872
1937
  }
1873
1938
 
1874
- // Prevent unbounded memory growth
1875
- if (seenMessages.size > SEEN_MESSAGES_MAX) {
1876
- const excess = seenMessages.size - SEEN_MESSAGES_MAX;
1877
- const iter = seenMessages.values();
1878
- for (let i = 0; i < excess; i++) seenMessages.delete(iter.next().value!);
1939
+ if (updates.hasMore) {
1940
+ setImmediate(poll);
1879
1941
  }
1880
1942
  } catch (err) {
1881
1943
  console.error(`[pairai] poll error: ${(err as Error).message}`);