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 +5 -0
- package/overlay/inbox.html +6 -0
- package/overlay/main.cjs +71 -4
- package/package.json +1 -1
- package/src/channel-server.js +165 -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,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))
|
|
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
|
@@ -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
|
+
}
|