relay-companion 0.1.82 → 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,10 +3097,16 @@
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); }
3103
- setRowNote(id, "Handed to your current Claude chat…", "ok");
3109
+ setRowNote(id, "Handed to your current Claude chat — lands when it next takes a turn. If it\u2019s idle: type anything there, or use Open in New Chat for instant.", "ok");
3104
3110
  return;
3105
3111
  }
3106
3112
  const hostName = info.host === "codex" ? "Codex" : "Claude";
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
@@ -1897,18 +1913,20 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1897
1913
  // injection for an idle session sat invisible for minutes and the click read as
1898
1914
  // completely dead; a waiting state that never lies beats a success toast.
1899
1915
  //
1900
- // The GRACE WINDOW closes the remaining gap. Nothing outside a session can make
1901
- // it take a turn (verified against the platform docs: deep links only pre-fill
1902
- // NEW sessions; no external submit API exists), so an idle chat cannot be woken
1903
- // — but the user must still get the relay QUICKLY. A busy chat ingests at a
1904
- // tool boundary within seconds; if the grace window passes unconsumed, the
1905
- // target is idle: reclaim the injection (same rename-first consume-once
1906
- // discipline as the hook, so a racing delivery always wins) and open the relay
1907
- // in a fresh chat instead. Every click produces a visible result in seconds.
1916
+ // Nothing outside a session can make it take a turn (verified against the
1917
+ // platform docs: deep links only pre-fill NEW sessions; no external submit API
1918
+ // exists), so an idle chat cannot be woken — the honest waiting/delivered
1919
+ // states are the truth until the Relay channel plugin (the platform's supported
1920
+ // push-into-a-running-session mechanism) lands. The 0.1.82 grace hop — reclaim
1921
+ // after 6s and open a fresh chat — is now OFF by default: the same-evening
1922
+ // field verdict was that silently converting "current chat" into "new chat"
1923
+ // betrays the button. The user chose current; Open in New Chat sits one row
1924
+ // below for when they want instant. RELAY_CURRENT_CHAT_GRACE_MS > 0 re-enables
1925
+ // the hop for anyone who prefers motion over fidelity.
1908
1926
  const injectionWatchers = new Map(); // packetId -> interval
1909
1927
  const AUTO_FRESH_GRACE_MS = (() => {
1910
1928
  const raw = Number(process.env.RELAY_CURRENT_CHAT_GRACE_MS);
1911
- return Number.isFinite(raw) && raw >= 0 ? raw : 6000; // 0 disables auto-fresh
1929
+ return Number.isFinite(raw) && raw > 0 ? raw : 0; // 0 (default): never hop
1912
1930
  })();
1913
1931
  function reclaimInjection(stagedPath) {
1914
1932
  const claimed = `${stagedPath}.${process.pid}.${Date.now()}.autofresh`;
@@ -1922,7 +1940,7 @@ function reclaimInjection(stagedPath) {
1922
1940
  } catch {}
1923
1941
  return true;
1924
1942
  }
1925
- 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 } = {}) {
1926
1944
  if (!stagedPath) return;
1927
1945
  const existing = injectionWatchers.get(packetId);
1928
1946
  if (existing) clearInterval(existing); // newest click owns the watch
@@ -1954,11 +1972,13 @@ function watchInjectionDelivery(packetId, stagedPath, { timeoutMs = 10 * 60 * 10
1954
1972
  onAutoFresh();
1955
1973
  return;
1956
1974
  }
1957
- // A restaged/overwritten click or a very long idle: stop polling quietly.
1958
- // 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).
1959
1978
  if (Date.now() - startedAt > timeoutMs) {
1960
1979
  clearInterval(timer);
1961
1980
  injectionWatchers.delete(packetId);
1981
+ if (onTimeout) onTimeout();
1962
1982
  }
1963
1983
  }, intervalMs);
1964
1984
  if (typeof timer.unref === "function") timer.unref();
@@ -2090,6 +2110,46 @@ async function openPacketInCurrent(packetId) {
2090
2110
  },
2091
2111
  });
2092
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
+ });
2093
2153
  // NOTHING but the `relay claude-hook` runtime registered in ~/.claude/settings.json
2094
2154
  // ever reads the staged file. Where setup never wrote those hooks (Windows installs
2095
2155
  // where the `claude` CLI isn't on PATH — see src/install.js runSetupInstall) staging
@@ -2098,7 +2158,16 @@ async function openPacketInCurrent(packetId) {
2098
2158
  // and finish the click twice.
2099
2159
  loadClaudeHooksModule().then(
2100
2160
  (install) => {
2101
- 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
+ }
2102
2171
  // Repair for FUTURE sessions — but a Claude session that is already running
2103
2172
  // loaded its hooks at startup and will never consume a file staged now, so this
2104
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.82",
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
+ }