relay-companion 0.1.83 → 0.1.85
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 +5 -0
- package/overlay/inbox.html +6 -0
- package/overlay/main.cjs +81 -4
- package/package.json +1 -1
- package/src/channel-server.js +191 -0
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(() => {});
|
package/overlay/inbox.html
CHANGED
|
@@ -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
|
-
//
|
|
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,56 @@ async function openPacketInCurrent(packetId) {
|
|
|
2092
2110
|
},
|
|
2093
2111
|
});
|
|
2094
2112
|
};
|
|
2113
|
+
// WAKE TIER (live-proven): when the resolved CURRENT chat is itself a
|
|
2114
|
+
// relay-channel session, push the instruction as a channel event — the
|
|
2115
|
+
// session takes a REAL turn on it even when idle, which hook staging can
|
|
2116
|
+
// never cause. The join is by working directory: the channel instance's
|
|
2117
|
+
// recorded cwd must uniquely match the target session's rendezvous cwd, so
|
|
2118
|
+
// a click aimed at a desktop chat NEVER wakes some other terminal session.
|
|
2119
|
+
// If the claimed event somehow never gets picked up (server died between
|
|
2120
|
+
// heartbeats), the watcher falls back to hook staging — never a dead end.
|
|
2121
|
+
const tryChannelWake = () =>
|
|
2122
|
+
loadChannelDeps().then((channel) => {
|
|
2123
|
+
let targetCwd = "";
|
|
2124
|
+
try {
|
|
2125
|
+
const rendezvousPath = path.join(
|
|
2126
|
+
claudeInject.rendezvousDir(RELAY_HOME),
|
|
2127
|
+
`${claudeInject.safeSessionKey(target.sessionId)}.json`,
|
|
2128
|
+
);
|
|
2129
|
+
targetCwd = String(JSON.parse(fs.readFileSync(rendezvousPath, "utf8")).cwd || "");
|
|
2130
|
+
} catch {
|
|
2131
|
+
return false; // no rendezvous -> no join evidence -> hook path
|
|
2132
|
+
}
|
|
2133
|
+
if (!channel.liveChannelInstanceForCwd(RELAY_HOME, targetCwd)) return false;
|
|
2134
|
+
const instruction = claudeInject.buildInjectionInstruction({
|
|
2135
|
+
relayId: packetId,
|
|
2136
|
+
senderName: row.senderName || "",
|
|
2137
|
+
title: row.title || row.displayTitle || "",
|
|
2138
|
+
...threadInfoFor(row),
|
|
2139
|
+
});
|
|
2140
|
+
const eventFile = channel.enqueueChannelEvent(RELAY_HOME, {
|
|
2141
|
+
content: instruction,
|
|
2142
|
+
meta: { relayId: packetId, source: "relay-pill-open-in-current" },
|
|
2143
|
+
});
|
|
2144
|
+
console.error(`[overlay] openInCurrent ${packetId}: pushed via relay channel (wake path)`);
|
|
2145
|
+
if (target.source === "desktop") activateHost("claude", bundle);
|
|
2146
|
+
confirmInjected("claude", { awaitingTurn: true, channel: true });
|
|
2147
|
+
finish();
|
|
2148
|
+
watchInjectionDelivery(packetId, eventFile, {
|
|
2149
|
+
autoFreshMs: 0,
|
|
2150
|
+
timeoutMs: 15_000,
|
|
2151
|
+
onTimeout: () => {
|
|
2152
|
+
// Presence lied (instance died between heartbeats): reclaim the
|
|
2153
|
+
// event and hand the click to the hook path so it still lands.
|
|
2154
|
+
console.error(`[overlay] openInCurrent ${packetId}: channel event unclaimed after 15s; falling back to hook staging`);
|
|
2155
|
+
try {
|
|
2156
|
+
fs.rmSync(eventFile, { force: true });
|
|
2157
|
+
} catch {}
|
|
2158
|
+
stageNow();
|
|
2159
|
+
},
|
|
2160
|
+
});
|
|
2161
|
+
return true;
|
|
2162
|
+
});
|
|
2095
2163
|
// NOTHING but the `relay claude-hook` runtime registered in ~/.claude/settings.json
|
|
2096
2164
|
// ever reads the staged file. Where setup never wrote those hooks (Windows installs
|
|
2097
2165
|
// where the `claude` CLI isn't on PATH — see src/install.js runSetupInstall) staging
|
|
@@ -2100,7 +2168,16 @@ async function openPacketInCurrent(packetId) {
|
|
|
2100
2168
|
// and finish the click twice.
|
|
2101
2169
|
loadClaudeHooksModule().then(
|
|
2102
2170
|
(install) => {
|
|
2103
|
-
if (claudeHooksInstalled(install))
|
|
2171
|
+
if (claudeHooksInstalled(install)) {
|
|
2172
|
+
return tryChannelWake()
|
|
2173
|
+
.catch((error) => {
|
|
2174
|
+
console.error("[overlay] channel wake unavailable:", error && error.message);
|
|
2175
|
+
return false;
|
|
2176
|
+
})
|
|
2177
|
+
.then((woke) => {
|
|
2178
|
+
if (!woke) stageNow();
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2104
2181
|
// Repair for FUTURE sessions — but a Claude session that is already running
|
|
2105
2182
|
// loaded its hooks at startup and will never consume a file staged now, so this
|
|
2106
2183
|
// click has to take the fresh-open path, the only one that actually shows the
|
package/package.json
CHANGED
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
// The single live channel instance matching a session's working directory, or
|
|
87
|
+
// null. "Current chat" means the chat the user is looking at: the wake tier
|
|
88
|
+
// must fire only when the channel session IS the resolved target, and the only
|
|
89
|
+
// join available today is cwd (channel presence records its process cwd; hook
|
|
90
|
+
// rendezvous records the session's). One instance per cwd; ambiguity -> null.
|
|
91
|
+
export function liveChannelInstanceForCwd(homeDir = storeDir(), cwd, { maxAgeMs = PRESENCE_MS * 3, nowMs = Date.now() } = {}) {
|
|
92
|
+
const wanted = String(cwd || "").replace(/\/+$/, "");
|
|
93
|
+
if (!wanted) return null;
|
|
94
|
+
let entries = [];
|
|
95
|
+
try {
|
|
96
|
+
entries = fs.readdirSync(channelPresenceDir(homeDir));
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const matches = [];
|
|
101
|
+
for (const name of entries) {
|
|
102
|
+
if (!name.endsWith(".json")) continue;
|
|
103
|
+
try {
|
|
104
|
+
const row = JSON.parse(fs.readFileSync(path.join(channelPresenceDir(homeDir), name), "utf8"));
|
|
105
|
+
if (nowMs - (Number(row.heartbeatAt) || 0) > maxAgeMs) continue;
|
|
106
|
+
if (String(row.cwd || "").replace(/\/+$/, "") === wanted) matches.push(row);
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
return matches.length === 1 ? matches[0] : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function consumeEventFile(file) {
|
|
113
|
+
const claimed = `${file}.${process.pid}.claimed`;
|
|
114
|
+
try {
|
|
115
|
+
fs.renameSync(file, claimed); // exactly-once across instances
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
let payload = null;
|
|
120
|
+
try {
|
|
121
|
+
payload = JSON.parse(fs.readFileSync(claimed, "utf8"));
|
|
122
|
+
} catch {
|
|
123
|
+
payload = null;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
fs.rmSync(claimed, { force: true });
|
|
127
|
+
} catch {}
|
|
128
|
+
return payload && typeof payload === "object" ? payload : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function runChannelServer({ homeDir = storeDir(), log = (m) => console.error(`[relay-channel] ${m}`) } = {}) {
|
|
132
|
+
const server = new Server(
|
|
133
|
+
{ name: "relay-channel", version: "0.1.0" },
|
|
134
|
+
// The experimental channel capability is what lets notifications push into
|
|
135
|
+
// the session; no tools are exposed (the relay MCP server already serves
|
|
136
|
+
// tools — this process exists ONLY to wake the session with events).
|
|
137
|
+
{ capabilities: { experimental: { "claude/channel": {} } } },
|
|
138
|
+
);
|
|
139
|
+
const transport = new StdioServerTransport();
|
|
140
|
+
await server.connect(transport);
|
|
141
|
+
log(`connected; watching ${channelQueueDir(homeDir)}`);
|
|
142
|
+
|
|
143
|
+
const presencePath = path.join(channelPresenceDir(homeDir), `${process.pid}.json`);
|
|
144
|
+
const heartbeat = () => {
|
|
145
|
+
try {
|
|
146
|
+
fs.mkdirSync(channelPresenceDir(homeDir), { recursive: true, mode: 0o700 });
|
|
147
|
+
fs.writeFileSync(presencePath, `${JSON.stringify({ pid: process.pid, cwd: process.cwd(), heartbeatAt: Date.now() })}\n`);
|
|
148
|
+
} catch {}
|
|
149
|
+
};
|
|
150
|
+
heartbeat();
|
|
151
|
+
const presenceTimer = setInterval(heartbeat, PRESENCE_MS);
|
|
152
|
+
if (presenceTimer.unref) presenceTimer.unref();
|
|
153
|
+
|
|
154
|
+
const pump = async () => {
|
|
155
|
+
let names = [];
|
|
156
|
+
try {
|
|
157
|
+
names = fs.readdirSync(channelQueueDir(homeDir)).filter((n) => n.startsWith("evt-") && n.endsWith(".json"));
|
|
158
|
+
} catch {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
names.sort(); // oldest first (timestamp-prefixed names)
|
|
162
|
+
for (const name of names) {
|
|
163
|
+
const payload = consumeEventFile(path.join(channelQueueDir(homeDir), name));
|
|
164
|
+
if (!payload) continue;
|
|
165
|
+
try {
|
|
166
|
+
await server.notification({
|
|
167
|
+
method: "notifications/claude/channel",
|
|
168
|
+
params: { content: String(payload.content || ""), meta: payload.meta || {} },
|
|
169
|
+
});
|
|
170
|
+
log(`pushed event ${name}`);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
log(`push failed for ${name}: ${error && error.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const pumpTimer = setInterval(() => void pump(), POLL_MS);
|
|
177
|
+
if (pumpTimer.unref) pumpTimer.unref();
|
|
178
|
+
void pump();
|
|
179
|
+
|
|
180
|
+
const cleanup = () => {
|
|
181
|
+
try {
|
|
182
|
+
fs.rmSync(presencePath, { force: true });
|
|
183
|
+
} catch {}
|
|
184
|
+
};
|
|
185
|
+
process.on("exit", cleanup);
|
|
186
|
+
process.on("SIGTERM", () => {
|
|
187
|
+
cleanup();
|
|
188
|
+
process.exit(0);
|
|
189
|
+
});
|
|
190
|
+
return { server, pump };
|
|
191
|
+
}
|