relay-companion 0.1.83 → 0.1.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/relay.js CHANGED
@@ -492,6 +492,11 @@ async function main() {
492
492
  return cmdPill(flags);
493
493
  case "mcp":
494
494
  return runMcpServer({ mode: modeFromFlags(flags) });
495
+ case "channel":
496
+ // EXPERIMENTAL: Claude Code channel server (see src/channel-server.js).
497
+ // Register in .mcp.json and start the session with
498
+ // claude --dangerously-load-development-channels server:relay
499
+ return import("../src/channel-server.js").then(({ runChannelServer }) => runChannelServer());
495
500
  case "update":
496
501
  case "self-update":
497
502
  return runUpdateOnce({ mode: modeFromFlags(flags) }).then(() => {});
@@ -3097,6 +3097,12 @@
3097
3097
  }, ms));
3098
3098
  }
3099
3099
  if (window.relay.onInjected) window.relay.onInjected((id, info) => {
3100
+ if (info.channel) {
3101
+ // Wake path: a channel event starts a real turn even in an idle chat.
3102
+ if (injectedNoteTimers.has(id)) { clearTimeout(injectedNoteTimers.get(id)); injectedNoteTimers.delete(id); }
3103
+ setRowNote(id, "Pushed to your current Claude chat — waking it now…", "ok");
3104
+ return;
3105
+ }
3100
3106
  if (info.awaitingTurn) {
3101
3107
  // Persistent until delivery / the auto-fresh hop — never a silent limbo.
3102
3108
  if (injectedNoteTimers.has(id)) { clearTimeout(injectedNoteTimers.get(id)); injectedNoteTimers.delete(id); }
package/overlay/main.cjs CHANGED
@@ -310,6 +310,22 @@ function loadSentStager() {
310
310
  return sentStagerPromise;
311
311
  }
312
312
 
313
+ // Relay channel deps (ESM, lazy): the wake path for "Open in current chat".
314
+ // A session started with the relay channel takes a REAL turn on a pushed
315
+ // event — the only supported way to wake an idle chat (live-proven 2026-08-05:
316
+ // idle terminal session answered a queued event with zero keystrokes).
317
+ let channelDepsPromise = null;
318
+ function loadChannelDeps() {
319
+ if (!channelDepsPromise) {
320
+ const url = pathToFileURL(path.join(__dirname, "..", "src", "channel-server.js")).href;
321
+ channelDepsPromise = import(url).catch((error) => {
322
+ channelDepsPromise = null;
323
+ throw error;
324
+ });
325
+ }
326
+ return channelDepsPromise;
327
+ }
328
+
313
329
  // Codex "Open in current chat" deps (ESM, loaded lazily like the modules
314
330
  // above): codex-inject resolves the current thread / stages the heartbeat
315
331
  // automation, codex-desktop drives the live bridge submit. The tier ordering
@@ -1924,7 +1940,7 @@ function reclaimInjection(stagedPath) {
1924
1940
  } catch {}
1925
1941
  return true;
1926
1942
  }
1927
- function watchInjectionDelivery(packetId, stagedPath, { timeoutMs = 10 * 60 * 1000, intervalMs = 500, autoFreshMs = AUTO_FRESH_GRACE_MS, onAutoFresh = null } = {}) {
1943
+ function watchInjectionDelivery(packetId, stagedPath, { timeoutMs = 10 * 60 * 1000, intervalMs = 500, autoFreshMs = AUTO_FRESH_GRACE_MS, onAutoFresh = null, onTimeout = null } = {}) {
1928
1944
  if (!stagedPath) return;
1929
1945
  const existing = injectionWatchers.get(packetId);
1930
1946
  if (existing) clearInterval(existing); // newest click owns the watch
@@ -1956,11 +1972,13 @@ function watchInjectionDelivery(packetId, stagedPath, { timeoutMs = 10 * 60 * 10
1956
1972
  onAutoFresh();
1957
1973
  return;
1958
1974
  }
1959
- // A restaged/overwritten click or a very long idle: stop polling quietly.
1960
- // The waiting note remains accurate — the injection is still pending.
1975
+ // A restaged/overwritten click or a very long idle: stop polling quietly
1976
+ // (the waiting note remains accurate — the injection is still pending),
1977
+ // unless the caller owns the timeout (channel wake falls back to staging).
1961
1978
  if (Date.now() - startedAt > timeoutMs) {
1962
1979
  clearInterval(timer);
1963
1980
  injectionWatchers.delete(packetId);
1981
+ if (onTimeout) onTimeout();
1964
1982
  }
1965
1983
  }, intervalMs);
1966
1984
  if (typeof timer.unref === "function") timer.unref();
@@ -2092,6 +2110,46 @@ async function openPacketInCurrent(packetId) {
2092
2110
  },
2093
2111
  });
2094
2112
  };
2113
+ // WAKE TIER (live-proven): when exactly one relay-channel session is alive,
2114
+ // push the instruction as a channel event — the session takes a REAL turn on
2115
+ // it even when idle, which hook staging can never cause. One instance only:
2116
+ // queue events are claimed first-rename-wins, so with several live channel
2117
+ // sessions the event could wake a chat the user is not looking at; the
2118
+ // session-targeted hook path stays correct for that world. If the claimed
2119
+ // event somehow never gets picked up (server died between heartbeats), the
2120
+ // watcher falls through to hook staging — the click never dead-ends.
2121
+ const tryChannelWake = () =>
2122
+ loadChannelDeps().then((channel) => {
2123
+ if (channel.countLiveChannelInstances(RELAY_HOME) !== 1) return false;
2124
+ const instruction = claudeInject.buildInjectionInstruction({
2125
+ relayId: packetId,
2126
+ senderName: row.senderName || "",
2127
+ title: row.title || row.displayTitle || "",
2128
+ ...threadInfoFor(row),
2129
+ });
2130
+ const eventFile = channel.enqueueChannelEvent(RELAY_HOME, {
2131
+ content: instruction,
2132
+ meta: { relayId: packetId, source: "relay-pill-open-in-current" },
2133
+ });
2134
+ console.error(`[overlay] openInCurrent ${packetId}: pushed via relay channel (wake path)`);
2135
+ if (target.source === "desktop") activateHost("claude", bundle);
2136
+ confirmInjected("claude", { awaitingTurn: true, channel: true });
2137
+ finish();
2138
+ watchInjectionDelivery(packetId, eventFile, {
2139
+ autoFreshMs: 0,
2140
+ timeoutMs: 15_000,
2141
+ onTimeout: () => {
2142
+ // Presence lied (instance died between heartbeats): reclaim the
2143
+ // event and hand the click to the hook path so it still lands.
2144
+ console.error(`[overlay] openInCurrent ${packetId}: channel event unclaimed after 15s; falling back to hook staging`);
2145
+ try {
2146
+ fs.rmSync(eventFile, { force: true });
2147
+ } catch {}
2148
+ stageNow();
2149
+ },
2150
+ });
2151
+ return true;
2152
+ });
2095
2153
  // NOTHING but the `relay claude-hook` runtime registered in ~/.claude/settings.json
2096
2154
  // ever reads the staged file. Where setup never wrote those hooks (Windows installs
2097
2155
  // where the `claude` CLI isn't on PATH — see src/install.js runSetupInstall) staging
@@ -2100,7 +2158,16 @@ async function openPacketInCurrent(packetId) {
2100
2158
  // and finish the click twice.
2101
2159
  loadClaudeHooksModule().then(
2102
2160
  (install) => {
2103
- if (claudeHooksInstalled(install)) return stageNow();
2161
+ if (claudeHooksInstalled(install)) {
2162
+ return tryChannelWake()
2163
+ .catch((error) => {
2164
+ console.error("[overlay] channel wake unavailable:", error && error.message);
2165
+ return false;
2166
+ })
2167
+ .then((woke) => {
2168
+ if (!woke) stageNow();
2169
+ });
2170
+ }
2104
2171
  // Repair for FUTURE sessions — but a Claude session that is already running
2105
2172
  // loaded its hooks at startup and will never consume a file staged now, so this
2106
2173
  // click has to take the fresh-open path, the only one that actually shows the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.83",
3
+ "version": "0.1.84",
4
4
  "description": "Relay companion for ordinary messages, with dormant coordination features available only by explicit opt-in.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,165 @@
1
+ // `relay channel` — EXPERIMENTAL Claude Code channel server.
2
+ //
3
+ // This is the real fix for "Open in Current Chat must wake an idle chat".
4
+ // Hook-file injection (claude-inject.cjs) can only deliver when the session
5
+ // takes a turn on its own; a channel event is the platform's supported way to
6
+ // PUSH into a running session — an idle session takes a turn on it (docs:
7
+ // channels-reference, notifications/claude/channel). Sessions opt in at start:
8
+ // claude --channels server:relay (registered in .mcp.json), or
9
+ // claude --dangerously-load-development-channels server:relay (dev)
10
+ // Whether Claude Code Desktop threads can enable channels is still an open
11
+ // platform question; terminal sessions work today.
12
+ //
13
+ // Protocol with the pill/daemon (filesystem, same discipline as claude-inject):
14
+ // RELAY_HOME/channel-queue/<name>.json — one event per file:
15
+ // { content: string, meta?: object, createdAt: ISO }
16
+ // Consume-once via rename-first, so N live channel instances deliver an
17
+ // event exactly once fleet-wide (first rename wins; losers stay silent).
18
+ // RELAY_HOME/channel-instances/<pid>.json — presence heartbeat so the pill
19
+ // knows a wake-capable session exists before preferring this path.
20
+ //
21
+ // The event content is an INSTRUCTION built by the same sanitized builders as
22
+ // hook injection (relay body is never inlined; the model fetches via MCP tools).
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
27
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
28
+ import { storeDir } from "./host-paths.js";
29
+
30
+ const QUEUE_DIR = "channel-queue";
31
+ const PRESENCE_DIR = "channel-instances";
32
+ const POLL_MS = 700; // fs.watch is unreliable across atomic renames; poll cheaply
33
+ const PRESENCE_MS = 15_000;
34
+
35
+ export function channelQueueDir(homeDir = storeDir()) {
36
+ return path.join(homeDir, QUEUE_DIR);
37
+ }
38
+
39
+ export function channelPresenceDir(homeDir = storeDir()) {
40
+ return path.join(homeDir, PRESENCE_DIR);
41
+ }
42
+
43
+ // Drop one event for whichever live channel session claims it first.
44
+ export function enqueueChannelEvent(homeDir, { content, meta = {} } = {}) {
45
+ const text = String(content || "").trim();
46
+ if (!text) throw new Error("enqueueChannelEvent requires content");
47
+ const dir = channelQueueDir(homeDir);
48
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
49
+ const file = path.join(dir, `evt-${Date.now()}-${process.pid}-${Math.floor(Math.random() * 1e6)}.json`);
50
+ const tmp = `${file}.tmp`;
51
+ fs.writeFileSync(tmp, `${JSON.stringify({ content: text, meta, createdAt: new Date().toISOString() })}\n`, { mode: 0o600 });
52
+ fs.renameSync(tmp, file);
53
+ return file;
54
+ }
55
+
56
+ // How many channel instances heartbeated recently. The pill prefers the wake
57
+ // path only when EXACTLY ONE live instance exists: queue events are claimed by
58
+ // whichever instance renames first, so with several live sessions the event
59
+ // could wake a different chat than the one the user is looking at — hook
60
+ // staging (session-targeted) stays the correct route until per-session
61
+ // channel targeting exists.
62
+ export function countLiveChannelInstances(homeDir = storeDir(), { maxAgeMs = PRESENCE_MS * 3, nowMs = Date.now() } = {}) {
63
+ let entries = [];
64
+ try {
65
+ entries = fs.readdirSync(channelPresenceDir(homeDir));
66
+ } catch {
67
+ return 0;
68
+ }
69
+ let live = 0;
70
+ for (const name of entries) {
71
+ if (!name.endsWith(".json")) continue;
72
+ try {
73
+ const row = JSON.parse(fs.readFileSync(path.join(channelPresenceDir(homeDir), name), "utf8"));
74
+ if (nowMs - (Number(row.heartbeatAt) || 0) <= maxAgeMs) live += 1;
75
+ } catch {}
76
+ }
77
+ return live;
78
+ }
79
+
80
+ // True when at least one channel instance heartbeated recently — the pill uses
81
+ // this to decide whether a wake-capable session exists at all.
82
+ export function channelInstanceAlive(homeDir = storeDir(), opts = {}) {
83
+ return countLiveChannelInstances(homeDir, opts) > 0;
84
+ }
85
+
86
+ function consumeEventFile(file) {
87
+ const claimed = `${file}.${process.pid}.claimed`;
88
+ try {
89
+ fs.renameSync(file, claimed); // exactly-once across instances
90
+ } catch {
91
+ return null;
92
+ }
93
+ let payload = null;
94
+ try {
95
+ payload = JSON.parse(fs.readFileSync(claimed, "utf8"));
96
+ } catch {
97
+ payload = null;
98
+ }
99
+ try {
100
+ fs.rmSync(claimed, { force: true });
101
+ } catch {}
102
+ return payload && typeof payload === "object" ? payload : null;
103
+ }
104
+
105
+ export async function runChannelServer({ homeDir = storeDir(), log = (m) => console.error(`[relay-channel] ${m}`) } = {}) {
106
+ const server = new Server(
107
+ { name: "relay-channel", version: "0.1.0" },
108
+ // The experimental channel capability is what lets notifications push into
109
+ // the session; no tools are exposed (the relay MCP server already serves
110
+ // tools — this process exists ONLY to wake the session with events).
111
+ { capabilities: { experimental: { "claude/channel": {} } } },
112
+ );
113
+ const transport = new StdioServerTransport();
114
+ await server.connect(transport);
115
+ log(`connected; watching ${channelQueueDir(homeDir)}`);
116
+
117
+ const presencePath = path.join(channelPresenceDir(homeDir), `${process.pid}.json`);
118
+ const heartbeat = () => {
119
+ try {
120
+ fs.mkdirSync(channelPresenceDir(homeDir), { recursive: true, mode: 0o700 });
121
+ fs.writeFileSync(presencePath, `${JSON.stringify({ pid: process.pid, cwd: process.cwd(), heartbeatAt: Date.now() })}\n`);
122
+ } catch {}
123
+ };
124
+ heartbeat();
125
+ const presenceTimer = setInterval(heartbeat, PRESENCE_MS);
126
+ if (presenceTimer.unref) presenceTimer.unref();
127
+
128
+ const pump = async () => {
129
+ let names = [];
130
+ try {
131
+ names = fs.readdirSync(channelQueueDir(homeDir)).filter((n) => n.startsWith("evt-") && n.endsWith(".json"));
132
+ } catch {
133
+ return;
134
+ }
135
+ names.sort(); // oldest first (timestamp-prefixed names)
136
+ for (const name of names) {
137
+ const payload = consumeEventFile(path.join(channelQueueDir(homeDir), name));
138
+ if (!payload) continue;
139
+ try {
140
+ await server.notification({
141
+ method: "notifications/claude/channel",
142
+ params: { content: String(payload.content || ""), meta: payload.meta || {} },
143
+ });
144
+ log(`pushed event ${name}`);
145
+ } catch (error) {
146
+ log(`push failed for ${name}: ${error && error.message}`);
147
+ }
148
+ }
149
+ };
150
+ const pumpTimer = setInterval(() => void pump(), POLL_MS);
151
+ if (pumpTimer.unref) pumpTimer.unref();
152
+ void pump();
153
+
154
+ const cleanup = () => {
155
+ try {
156
+ fs.rmSync(presencePath, { force: true });
157
+ } catch {}
158
+ };
159
+ process.on("exit", cleanup);
160
+ process.on("SIGTERM", () => {
161
+ cleanup();
162
+ process.exit(0);
163
+ });
164
+ return { server, pump };
165
+ }