relay-companion 0.1.0

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/src/client.js ADDED
@@ -0,0 +1,168 @@
1
+ import { apiUrl, deviceToken } from "./config.js";
2
+
3
+ /** Thin authenticated client for relay-api, used by the CLI, MCP server, and daemon. */
4
+ export class RelayClient {
5
+ constructor({ url = apiUrl(), token = deviceToken() } = {}) {
6
+ this.url = url.replace(/\/$/, "");
7
+ this.token = token;
8
+ }
9
+
10
+ async #req(method, path, body, { auth = true } = {}) {
11
+ const headers = { "Content-Type": "application/json" };
12
+ if (auth && this.token) headers.Authorization = `Bearer ${this.token}`;
13
+ const res = await fetch(`${this.url}${path}`, {
14
+ method,
15
+ headers,
16
+ body: body === undefined ? undefined : JSON.stringify(body),
17
+ });
18
+ const text = await res.text();
19
+ const data = text ? JSON.parse(text) : {};
20
+ if (!res.ok) {
21
+ const err = new Error(data.message || data.error || `HTTP ${res.status}`);
22
+ err.status = res.status;
23
+ err.body = data;
24
+ throw err;
25
+ }
26
+ return data;
27
+ }
28
+
29
+ me() {
30
+ return this.#req("GET", "/v1/me");
31
+ }
32
+
33
+ registerDevice({ pairingCode, name, platform }) {
34
+ return this.#req("POST", "/v1/devices/register", { pairingCode, name, platform }, { auth: false });
35
+ }
36
+
37
+ createTask(payload) {
38
+ return this.#req("POST", "/v1/tasks", payload);
39
+ }
40
+
41
+ listTasks() {
42
+ return this.#req("GET", "/v1/tasks");
43
+ }
44
+
45
+ listRelays() {
46
+ return this.#req("GET", "/v1/task-relays");
47
+ }
48
+
49
+ createFileUpload(payload) {
50
+ return this.#req("POST", "/v1/files", payload);
51
+ }
52
+
53
+ fileDownload(fileId) {
54
+ return this.#req("GET", `/v1/files/${encodeURIComponent(fileId)}/download`);
55
+ }
56
+
57
+ openTaskInvitation(token) {
58
+ return this.#req("GET", `/v1/task-invitations/${encodeURIComponent(token)}`, undefined, { auth: false });
59
+ }
60
+
61
+ bindTaskInvitation(token, payload = {}) {
62
+ return this.#req("POST", "/v1/task-invitations/bind", { token, ...payload });
63
+ }
64
+
65
+ getTask(taskId) {
66
+ return this.#req("GET", `/v1/tasks/${encodeURIComponent(taskId)}`);
67
+ }
68
+
69
+ acceptTask(taskId, participantId, payload) {
70
+ return this.#req(
71
+ "POST",
72
+ `/v1/tasks/${encodeURIComponent(taskId)}/invitations/${encodeURIComponent(participantId)}/accept`,
73
+ payload,
74
+ );
75
+ }
76
+
77
+ rejectTask(taskId, participantId, payload) {
78
+ return this.#req(
79
+ "POST",
80
+ `/v1/tasks/${encodeURIComponent(taskId)}/invitations/${encodeURIComponent(participantId)}/reject`,
81
+ payload,
82
+ );
83
+ }
84
+
85
+ createTaskMessage(taskId, payload) {
86
+ return this.#req("POST", `/v1/tasks/${encodeURIComponent(taskId)}/messages`, payload);
87
+ }
88
+
89
+ answerHumanQuestion(taskId, messageId, payload) {
90
+ return this.#req(
91
+ "POST",
92
+ `/v1/tasks/${encodeURIComponent(taskId)}/messages/${encodeURIComponent(messageId)}/human-response`,
93
+ payload,
94
+ );
95
+ }
96
+
97
+ approveShare(taskId, approvalId, payload) {
98
+ return this.#req(
99
+ "POST",
100
+ `/v1/tasks/${encodeURIComponent(taskId)}/approvals/${encodeURIComponent(approvalId)}/approve`,
101
+ payload,
102
+ );
103
+ }
104
+
105
+ declineShare(taskId, approvalId, payload) {
106
+ return this.#req(
107
+ "POST",
108
+ `/v1/tasks/${encodeURIComponent(taskId)}/approvals/${encodeURIComponent(approvalId)}/decline`,
109
+ payload,
110
+ );
111
+ }
112
+
113
+ completeTask(taskId, payload) {
114
+ return this.#req("POST", `/v1/tasks/${encodeURIComponent(taskId)}/results`, payload);
115
+ }
116
+
117
+ taskEvents(taskId) {
118
+ return this.#req("GET", `/v1/tasks/${encodeURIComponent(taskId)}/events`);
119
+ }
120
+
121
+ agentInbox() {
122
+ return this.#req("GET", "/v1/tasks/agent-inbox");
123
+ }
124
+
125
+ heartbeatSession(sessionId, payload) {
126
+ return this.#req("POST", `/v1/task-agent-sessions/${encodeURIComponent(sessionId)}/heartbeat`, payload);
127
+ }
128
+
129
+ postDaemonEvent(taskId, payload) {
130
+ return this.#req("POST", `/v1/tasks/${encodeURIComponent(taskId)}/daemon-events`, payload);
131
+ }
132
+
133
+ listConnectors() {
134
+ return this.#req("GET", "/v1/connectors");
135
+ }
136
+
137
+ toolCatalog() {
138
+ return this.#req("GET", "/v1/tools/catalog");
139
+ }
140
+
141
+ requestToolApproval(payload) {
142
+ return this.#req("POST", "/v1/tools/approvals", payload);
143
+ }
144
+
145
+ callTool(payload) {
146
+ return this.#req("POST", "/v1/tools/call", payload);
147
+ }
148
+
149
+ searchContacts(q) {
150
+ return this.#req("GET", `/v1/contacts/search?q=${encodeURIComponent(q)}`);
151
+ }
152
+
153
+ listContacts() {
154
+ return this.#req("GET", "/v1/contacts");
155
+ }
156
+
157
+ upsertContact({ name, email, emails }) {
158
+ return this.#req("POST", "/v1/contacts", { name, email, emails });
159
+ }
160
+
161
+ updateContact(contactId, { name, email, emails } = {}) {
162
+ return this.#req("PATCH", `/v1/contacts/${encodeURIComponent(contactId)}`, { name, email, emails });
163
+ }
164
+
165
+ importContacts(contacts, source = "imported") {
166
+ return this.#req("POST", "/v1/contacts/import", { contacts, source });
167
+ }
168
+ }
@@ -0,0 +1,120 @@
1
+ // Codex `app-server` JSON-RPC client. Ported verbatim from
2
+ // granular/tools/relay-companion/src/codex-app-server.js (require -> import only).
3
+ // Drives thread/start, thread/name/set, thread/read against a short-lived
4
+ // `codex app-server` process so a Relay row becomes a real native Codex thread.
5
+
6
+ import { spawn } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import readline from "node:readline";
9
+
10
+ export class CodexAppServerClient {
11
+ constructor({ command = defaultCodexCommand(), args = ["app-server"], cwd = process.cwd() } = {}) {
12
+ this.command = command;
13
+ this.args = args;
14
+ this.cwd = cwd;
15
+ this.nextId = 1;
16
+ this.pending = new Map();
17
+ this.notifications = [];
18
+ this.proc = null;
19
+ }
20
+
21
+ async start() {
22
+ if (this.proc) return;
23
+ this.proc = spawn(this.command, this.args, {
24
+ cwd: this.cwd,
25
+ stdio: ["pipe", "pipe", "pipe"],
26
+ env: { ...process.env },
27
+ });
28
+ this.proc.stderr.on("data", (chunk) => {
29
+ if (process.env.RELAY_DEBUG) process.stderr.write(chunk);
30
+ });
31
+ this.proc.on("error", (error) => {
32
+ for (const { reject } of this.pending.values()) reject(error);
33
+ this.pending.clear();
34
+ this.proc = null;
35
+ });
36
+ readline.createInterface({ input: this.proc.stdout, crlfDelay: Infinity }).on("line", (line) => {
37
+ if (!line.trim()) return;
38
+ const message = JSON.parse(line);
39
+ if (message.id != null && this.pending.has(message.id)) {
40
+ const { resolve, reject } = this.pending.get(message.id);
41
+ this.pending.delete(message.id);
42
+ if (message.error) reject(new Error(message.error.message || JSON.stringify(message.error)));
43
+ else resolve(message.result);
44
+ } else {
45
+ this.notifications.push(message);
46
+ }
47
+ });
48
+ this.proc.on("exit", (code, signal) => {
49
+ const error = new Error(`codex app-server exited with ${signal || code}`);
50
+ for (const { reject } of this.pending.values()) reject(error);
51
+ this.pending.clear();
52
+ this.proc = null;
53
+ });
54
+
55
+ await this.request("initialize", {
56
+ clientInfo: {
57
+ name: "granular_relay_companion",
58
+ title: "Relay Companion",
59
+ version: "0.1.0",
60
+ },
61
+ capabilities: {
62
+ experimentalApi: true,
63
+ optOutNotificationMethods: ["item/agentMessage/delta", "command/exec/outputDelta", "process/outputDelta"],
64
+ },
65
+ });
66
+ this.notify("initialized", {});
67
+ }
68
+
69
+ async stop() {
70
+ if (!this.proc) return;
71
+ this.proc.kill("SIGTERM");
72
+ this.proc = null;
73
+ }
74
+
75
+ async request(method, params = {}) {
76
+ if (!this.proc) throw new Error("Codex app-server is not started");
77
+ const id = this.nextId++;
78
+ const payload = { method, id, params };
79
+ const promise = new Promise((resolve, reject) => {
80
+ const timeout = setTimeout(() => {
81
+ this.pending.delete(id);
82
+ reject(new Error(`Timed out waiting for ${method}`));
83
+ }, Number(process.env.RELAY_APP_SERVER_TIMEOUT_MS || 60000));
84
+ this.pending.set(id, {
85
+ resolve: (value) => {
86
+ clearTimeout(timeout);
87
+ resolve(value);
88
+ },
89
+ reject: (error) => {
90
+ clearTimeout(timeout);
91
+ reject(error);
92
+ },
93
+ });
94
+ });
95
+ this.proc.stdin.write(`${JSON.stringify(payload)}\n`);
96
+ return promise;
97
+ }
98
+
99
+ notify(method, params = {}) {
100
+ if (!this.proc) throw new Error("Codex app-server is not started");
101
+ this.proc.stdin.write(`${JSON.stringify({ method, params })}\n`);
102
+ }
103
+ }
104
+
105
+ export async function withCodexAppServer(fn, options = {}) {
106
+ const client = new CodexAppServerClient(options);
107
+ await client.start();
108
+ try {
109
+ return await fn(client);
110
+ } finally {
111
+ await client.stop();
112
+ }
113
+ }
114
+
115
+ function defaultCodexCommand() {
116
+ if (process.env.CODEX_CLI_PATH) return process.env.CODEX_CLI_PATH;
117
+ const appBundledCodex = "/Applications/Codex.app/Contents/Resources/codex";
118
+ if (fs.existsSync(appBundledCodex)) return appBundledCodex;
119
+ return "codex";
120
+ }
@@ -0,0 +1,276 @@
1
+ // Codex Desktop foregrounding driver. Ported faithfully from
2
+ // granular/tools/relay-companion/src/codex-desktop.js. Finds the running Codex
3
+ // main process, opens (or SIGUSR1-starts) its Node inspector, and runs a
4
+ // Runtime.evaluate -> webContents.executeJavaScript -> electronBridge expression
5
+ // in every BrowserWindow to refresh the recents rail and navigate-to-route the
6
+ // freshly materialized Relay thread. Logic/constants are unchanged.
7
+ //
8
+ // Only adaptation: the original `import WsWebSocket from "ws"` is made optional.
9
+ // Node 22+ exposes a global WebSocket (which the original already preferred), so
10
+ // we use that; the `ws` package is loaded lazily only as a fallback and never at
11
+ // module-load time, so this module imports cleanly without the dependency.
12
+
13
+ import { execFile } from "node:child_process";
14
+ import http from "node:http";
15
+ import { setTimeout as sleep } from "node:timers/promises";
16
+ import { promisify } from "node:util";
17
+
18
+ const execFileAsync = promisify(execFile);
19
+ const INSPECTOR_HOST = "127.0.0.1";
20
+ const INSPECTOR_PORTS = Array.from({ length: 11 }, (_, index) => 9229 + index);
21
+ const CODEX_MAIN_PATH = "/Applications/Codex.app/Contents/MacOS/Codex";
22
+
23
+ let wsFallbackPromise = null;
24
+ async function resolveWebSocketImpl() {
25
+ if (typeof WebSocket === "function") return WebSocket;
26
+ if (!wsFallbackPromise) {
27
+ wsFallbackPromise = import("ws")
28
+ .then((mod) => mod.default || mod.WebSocket || mod)
29
+ .catch(() => null);
30
+ }
31
+ const ws = await wsFallbackPromise;
32
+ if (!ws) throw new Error("No WebSocket implementation available (global WebSocket missing and 'ws' not installed)");
33
+ return ws;
34
+ }
35
+
36
+ export async function notifyCodexDesktopThread({ threadId, pinnedThreadIds = null, timeoutMs, open = false } = {}) {
37
+ return notifyCodexDesktopThreads({
38
+ threadIds: threadId ? [threadId] : [],
39
+ pinnedThreadIds,
40
+ timeoutMs,
41
+ openThreadId: open ? threadId : null,
42
+ });
43
+ }
44
+
45
+ export async function notifyCodexDesktopThreads({
46
+ threadIds = [],
47
+ pinnedThreadIds = null,
48
+ openThreadId = null,
49
+ timeoutMs = Number(process.env.RELAY_CODEX_DESKTOP_TIMEOUT_MS || 4000),
50
+ } = {}) {
51
+ if (process.env.RELAY_CODEX_DESKTOP_REFRESH === "0") return { attempted: false, reason: "disabled" };
52
+ if (process.platform !== "darwin") return { attempted: false, reason: "not-darwin" };
53
+
54
+ const openId = String(openThreadId || "").trim() || null;
55
+ const ids = uniqueStrings(openId ? [...threadIds, openId] : threadIds);
56
+ const pinnedIds = Array.isArray(pinnedThreadIds) ? uniqueStrings(pinnedThreadIds) : null;
57
+ if (!ids.length && !pinnedIds && !openId) return { attempted: false, reason: "nothing-to-refresh" };
58
+
59
+ const pids = await findCodexMainPids();
60
+ if (!pids.length) return { attempted: true, ok: false, reason: "codex-not-running", results: [] };
61
+
62
+ const expression = buildCodexDesktopRefreshExpression({ threadIds: ids, pinnedThreadIds: pinnedIds, openThreadId: openId });
63
+ const results = [];
64
+ for (const pid of pids) {
65
+ try {
66
+ const target = await findOrStartInspectorForPid(pid, { timeoutMs });
67
+ if (!target) {
68
+ results.push({ pid, ok: false, reason: "inspector-unavailable" });
69
+ continue;
70
+ }
71
+ const value = await evaluateInspectorExpression(target.webSocketDebuggerUrl, expression, { timeoutMs });
72
+ results.push({ pid, ok: true, value });
73
+ } catch (error) {
74
+ results.push({ pid, ok: false, error: errorMessage(error) });
75
+ }
76
+ }
77
+
78
+ return { attempted: true, ok: results.some((result) => result.ok), results };
79
+ }
80
+
81
+ export function buildCodexDesktopRefreshExpression({ threadIds = [], pinnedThreadIds = null, openThreadId = null } = {}) {
82
+ const rendererCode = `(${async function relayRefreshCodexRenderer(payload) {
83
+ const hostId = "local";
84
+ const bridge = window.electronBridge;
85
+ if (!bridge?.sendMessageFromView) return { ok: false, reason: "missing-electron-bridge" };
86
+
87
+ const sent = [];
88
+ async function send(message) {
89
+ try {
90
+ await bridge.sendMessageFromView(message);
91
+ sent.push({ type: message.type, ok: true });
92
+ } catch (error) {
93
+ sent.push({ type: message.type, ok: false, error: String(error?.message || error) });
94
+ }
95
+ }
96
+
97
+ if (Array.isArray(payload.pinnedThreadIds)) {
98
+ await send({ type: "set-pinned-thread-ids-for-host", hostId, threadIds: payload.pinnedThreadIds });
99
+ }
100
+ if (payload.threadIds.length) {
101
+ await send({ type: "hydrate-pinned-threads", hostId, threadIds: payload.threadIds });
102
+ }
103
+ await send({ type: "refresh-recent-conversations-for-host", hostId });
104
+ for (const threadId of payload.threadIds) {
105
+ await send({ type: "broadcast-conversation-snapshot-for-host", hostId, conversationId: threadId });
106
+ }
107
+ if (payload.openThreadId) {
108
+ try {
109
+ window.postMessage(
110
+ {
111
+ type: "navigate-to-route",
112
+ path: `/hotkey-window/thread/${encodeURIComponent(String(payload.openThreadId))}`,
113
+ },
114
+ location.origin,
115
+ );
116
+ sent.push({ type: "navigate-to-route", ok: true, threadId: payload.openThreadId });
117
+ } catch (error) {
118
+ sent.push({ type: "navigate-to-route", ok: false, error: String(error?.message || error) });
119
+ }
120
+ }
121
+
122
+ return { ok: sent.some((item) => item.ok), href: location.href, openThreadId: payload.openThreadId || null, sent };
123
+ }.toString()})(${JSON.stringify({
124
+ threadIds: uniqueStrings(threadIds),
125
+ pinnedThreadIds,
126
+ openThreadId: String(openThreadId || "").trim() || null,
127
+ })})`;
128
+
129
+ return `(async () => {
130
+ const req = process.mainModule?.require || process.getBuiltinModule("module").createRequire(process.cwd() + "/");
131
+ const { BrowserWindow } = req("electron");
132
+ const code = ${JSON.stringify(rendererCode)};
133
+ const windows = [];
134
+ for (const win of BrowserWindow.getAllWindows()) {
135
+ if (win.isDestroyed()) continue;
136
+ try {
137
+ windows.push({
138
+ id: win.id,
139
+ visible: win.isVisible(),
140
+ url: win.webContents.getURL(),
141
+ result: await win.webContents.executeJavaScript(code, true),
142
+ });
143
+ } catch (error) {
144
+ windows.push({ id: win.id, ok: false, error: String(error?.message || error) });
145
+ }
146
+ }
147
+ return windows;
148
+ })()`;
149
+ }
150
+
151
+ async function findCodexMainPids() {
152
+ try {
153
+ const { stdout } = await execFileAsync("ps", ["-Ao", "pid=,command="], { timeout: 1500, maxBuffer: 1024 * 1024 });
154
+ return stdout
155
+ .split("\n")
156
+ .map((line) => line.trim().match(/^(\d+)\s+(.+)$/))
157
+ .filter(Boolean)
158
+ .filter((match) => match[2] === CODEX_MAIN_PATH || match[2].startsWith(`${CODEX_MAIN_PATH} `))
159
+ .map((match) => Number(match[1]))
160
+ .filter(Number.isFinite);
161
+ } catch {
162
+ return [];
163
+ }
164
+ }
165
+
166
+ async function findOrStartInspectorForPid(pid, { timeoutMs }) {
167
+ let target = await findInspectorTargetForPid(pid);
168
+ if (target) return target;
169
+
170
+ try {
171
+ process.kill(pid, "SIGUSR1");
172
+ } catch {
173
+ return null;
174
+ }
175
+
176
+ const deadline = Date.now() + Math.max(500, timeoutMs);
177
+ do {
178
+ await sleep(100);
179
+ target = await findInspectorTargetForPid(pid);
180
+ if (target) return target;
181
+ } while (Date.now() < deadline);
182
+ return null;
183
+ }
184
+
185
+ async function findInspectorTargetForPid(pid) {
186
+ for (const port of INSPECTOR_PORTS) {
187
+ const targets = await listInspectorTargets(port);
188
+ for (const target of targets) {
189
+ if (!target.webSocketDebuggerUrl) continue;
190
+ try {
191
+ const value = await evaluateInspectorExpression(target.webSocketDebuggerUrl, "process.pid", { timeoutMs: 800 });
192
+ if (value === pid) return target;
193
+ } catch {
194
+ // Try the next inspector target.
195
+ }
196
+ }
197
+ }
198
+ return null;
199
+ }
200
+
201
+ function listInspectorTargets(port) {
202
+ return new Promise((resolve) => {
203
+ const req = http.get({ host: INSPECTOR_HOST, port, path: "/json/list", timeout: 300 }, (res) => {
204
+ let body = "";
205
+ res.setEncoding("utf8");
206
+ res.on("data", (chunk) => {
207
+ body += chunk;
208
+ });
209
+ res.on("end", () => {
210
+ try {
211
+ const parsed = JSON.parse(body);
212
+ resolve(Array.isArray(parsed) ? parsed : []);
213
+ } catch {
214
+ resolve([]);
215
+ }
216
+ });
217
+ });
218
+ req.on("timeout", () => {
219
+ req.destroy();
220
+ resolve([]);
221
+ });
222
+ req.on("error", () => resolve([]));
223
+ });
224
+ }
225
+
226
+ async function evaluateInspectorExpression(webSocketDebuggerUrl, expression, { timeoutMs = 4000 } = {}) {
227
+ const WebSocketImpl = await resolveWebSocketImpl();
228
+ return new Promise((resolve, reject) => {
229
+ const ws = new WebSocketImpl(webSocketDebuggerUrl);
230
+ const timer = setTimeout(() => {
231
+ try {
232
+ ws.close();
233
+ } catch {}
234
+ reject(new Error("Timed out talking to Codex inspector"));
235
+ }, timeoutMs);
236
+ const pending = new Map();
237
+ let nextId = 1;
238
+
239
+ ws.onopen = () => {
240
+ const id = nextId++;
241
+ pending.set(id, (message) => {
242
+ if (message.exceptionDetails) {
243
+ reject(new Error(message.exceptionDetails.text || message.result?.result?.description || "Inspector evaluation failed"));
244
+ return;
245
+ }
246
+ resolve(message.result?.result?.value);
247
+ });
248
+ ws.send(JSON.stringify({ id, method: "Runtime.evaluate", params: { expression, awaitPromise: true, returnByValue: true } }));
249
+ };
250
+ ws.onerror = () => reject(new Error("Failed to connect to Codex inspector"));
251
+ ws.onmessage = (event) => {
252
+ let message;
253
+ try {
254
+ message = JSON.parse(event.data);
255
+ } catch {
256
+ return;
257
+ }
258
+ const handler = pending.get(message.id);
259
+ if (!handler) return;
260
+ pending.delete(message.id);
261
+ clearTimeout(timer);
262
+ try {
263
+ ws.close();
264
+ } catch {}
265
+ handler(message);
266
+ };
267
+ });
268
+ }
269
+
270
+ function uniqueStrings(values) {
271
+ return Array.from(new Set(values.map((value) => String(value || "").trim()).filter(Boolean)));
272
+ }
273
+
274
+ function errorMessage(error) {
275
+ return error instanceof Error ? error.message : String(error);
276
+ }