pairai 0.5.1 → 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 (2) hide show
  1. package/package.json +1 -1
  2. package/pairai.ts +96 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pairai",
3
- "version": "0.5.1",
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",
package/pairai.ts CHANGED
@@ -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
  */
@@ -96,6 +97,7 @@ if (command === "help" || args.includes("--help") || args.includes("-h")) {
96
97
  console.log(" PAIRAI_AGENT_CRED Agent API key");
97
98
  console.log(" PAIRAI_KEY_FILE Path to RSA private key .pem");
98
99
  console.log(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
100
+ console.log(" PAIRAI_CHANNEL_NOTIFICATIONS=1 Poll acks cursor (Claude --channel)");
99
101
  console.log(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
100
102
  console.log("\nExamples:");
101
103
  console.log(' npx pairai setup "My Assistant"');
@@ -515,6 +517,7 @@ if (command !== "serve") {
515
517
  console.error(" PAIRAI_KEY_FILE Path to RSA private key .pem file");
516
518
  console.error(" PAIRAI_POLL_MS Poll interval in ms (default: 5000)");
517
519
  console.error(" PAIRAI_LOCK_DIR Lock file directory (default: ~/.pairai/locks)");
520
+ console.error(" PAIRAI_CHANNEL_NOTIFICATIONS=1 Poll acks cursor (Claude --channel)");
518
521
  console.error(" PAIRAI_DEBUG=1 Verbose log to ~/.pairai/debug.log");
519
522
  console.error(" PAIRAI_DEBUG=<path> Verbose log to custom file");
520
523
  process.exit(1);
@@ -1128,9 +1131,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1128
1131
  parts.push(`**Unread messages:**\n${enriched.join("\n")}`);
1129
1132
  }
1130
1133
 
1131
- // 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.
1132
1136
  if (updates.cursor > 0) {
1133
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
+ }
1134
1142
  }
1135
1143
 
1136
1144
  return { content: [{ type: "text" as const, text: parts.join("\n\n") }] };
@@ -1896,12 +1904,81 @@ async function deliverEventNotification(event: {
1896
1904
  }
1897
1905
  }
1898
1906
 
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";
1912
+
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
+ }
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;
1958
+ }
1959
+ pid = nextPid;
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();
1968
+
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
+
1899
1973
  async function poll() {
1900
1974
  try {
1901
1975
  // Refresh public keys to pick up new connections
1902
1976
  await loadPublicKeys();
1903
1977
 
1904
- const updates = (await hubGet("/events")) as {
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 {
1905
1982
  events: Array<{
1906
1983
  id: number;
1907
1984
  type: string;
@@ -1914,7 +1991,7 @@ async function poll() {
1914
1991
  hasMore: boolean;
1915
1992
  };
1916
1993
 
1917
- debugLog(`poll: ${updates.events.length} events, cursor=${updates.cursor}, hasMore=${updates.hasMore}`);
1994
+ debugLog(`poll: ${updates.events.length} events, cursor=${updates.cursor}, hasMore=${updates.hasMore}${channelCapable ? "" : `, localCursor=${lastNotifiedEventId}`}`);
1918
1995
 
1919
1996
  if (updates.events.length === 0) return;
1920
1997
 
@@ -1926,14 +2003,20 @@ async function poll() {
1926
2003
  }
1927
2004
  }
1928
2005
 
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}`);
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
+ }
1936
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}`);
1937
2020
  }
1938
2021
 
1939
2022
  if (updates.hasMore) {
@@ -1948,7 +2031,9 @@ async function poll() {
1948
2031
  // ── Start ────────────────────────────────────────────────────────────────────
1949
2032
 
1950
2033
  await mcp.connect(new StdioServerTransport());
1951
- 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
+
1952
2037
  await loadAgentInfo();
1953
2038
  if (!myAgentId) {
1954
2039
  console.error("[pairai] failed to load agent info from hub. Cannot start polling.");