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 +5 -0
- package/overlay/inbox.html +7 -1
- package/overlay/main.cjs +82 -13
- 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,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
|
|
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
|
-
//
|
|
1901
|
-
//
|
|
1902
|
-
//
|
|
1903
|
-
//
|
|
1904
|
-
//
|
|
1905
|
-
//
|
|
1906
|
-
//
|
|
1907
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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))
|
|
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
|
@@ -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
|
+
}
|