pairai 0.5.0 → 0.5.2

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