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.
@@ -0,0 +1,378 @@
1
+ // Lazy-open session-materialization orchestrator.
2
+ //
3
+ // Ported from granular/tools/relay-companion/src/materializer.js. The HOST side
4
+ // (createCodexThread + the codex app-server / rollout / index-marker / state-db /
5
+ // pin / desktop-inspector sequence, and the Claude native-session forge + import
6
+ // + title-repair) is faithful to the original. The INPUT side is adapted: instead
7
+ // of loading an on-disk granular packet, openRelay reads the cloud companion row
8
+ // staged in RELAY_HOME/state.json (packets.<id>) — or its snapshotted contentPath,
9
+ // or fetches the relay body via RelayClient if only a pointer exists — and maps it.
10
+ //
11
+ // openRelay({ id, host, log }) returns { url, skipExternalOpen, openedInHost } so
12
+ // the CLI prints the same contract the original cli.js did.
13
+
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { materializeRowForClaude } from "./claude-materializer.js";
18
+ import {
19
+ ensureClaudeDesktopImported,
20
+ isClaudeNativeSessionImported,
21
+ repairClaudeDesktopRelaySessions,
22
+ } from "./claude-session-writer.js";
23
+ import {
24
+ appendVisibleAssistantTurn,
25
+ ensureCodexThreadIndexMarker,
26
+ findCodexSessionPath,
27
+ } from "./codex-session-writer.js";
28
+ import { withCodexAppServer } from "./codex-app-server.js";
29
+ import { codexThreadRowExists, finalizeCodexThreadState, relayThreadPreview } from "./codex-state.js";
30
+ import { notifyCodexDesktopThreads } from "./codex-desktop.js";
31
+ import { readPinnedThreadIds, setThreadPinned } from "./pinning.js";
32
+ import { relayRowTitle, renderRelayRowBriefing, renderRelayRowSeed, renderTaskOpenBriefing } from "./relay-briefing.js";
33
+ import { storeDir } from "./host-paths.js";
34
+
35
+ function companionStatePath() {
36
+ return path.join(storeDir(), "state.json");
37
+ }
38
+
39
+ function readState() {
40
+ try {
41
+ return JSON.parse(fs.readFileSync(companionStatePath(), "utf8")) || {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ function writeStateAtomic(state) {
48
+ const statePath = companionStatePath();
49
+ fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
50
+ const tmp = `${statePath}.${process.pid}.${Date.now()}.tmp`;
51
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
52
+ fs.renameSync(tmp, statePath);
53
+ }
54
+
55
+ function getRowState(id) {
56
+ const state = readState();
57
+ return (state.packets && state.packets[id]) || null;
58
+ }
59
+
60
+ // Persist materialization results back onto the staged row, so a re-open reuses
61
+ // the existing native session instead of forging a new one each click.
62
+ function rememberRow(id, patch) {
63
+ const state = readState();
64
+ state.packets ||= {};
65
+ const existing = state.packets[id] || {};
66
+ const next = { ...existing, ...patch, updatedAt: new Date().toISOString() };
67
+ state.packets[id] = next;
68
+ writeStateAtomic(state);
69
+ return next;
70
+ }
71
+
72
+ // Read the snapshotted packet content (written at stage time) if present. This is
73
+ // the durable copy that survives independently of the live row.
74
+ function readRowContent(rowState) {
75
+ for (const candidate of [rowState?.contentPath, rowState?.filePath]) {
76
+ if (!candidate || !fs.existsSync(candidate)) continue;
77
+ try {
78
+ return JSON.parse(fs.readFileSync(candidate, "utf8"));
79
+ } catch {}
80
+ }
81
+ return null;
82
+ }
83
+
84
+ // Resolve the materialization input for a staged row: merge the live row with its
85
+ // snapshotted packet content, and — when the row points at a task — fetch the
86
+ // verified task object via the RelayClient so the per-kind seed is grounded in the
87
+ // real API. Returns a normalized "row" the host writers consume.
88
+ async function resolveRow(id, { log = () => {} } = {}) {
89
+ const rowState = getRowState(id);
90
+ if (!rowState) throw new Error(`Unknown Relay row: ${id}`);
91
+ const content = readRowContent(rowState);
92
+ // The staged content packet carries the same fields under different names
93
+ // (briefingMarkdown/displayTitle/sender). Merge so either source satisfies the
94
+ // host writers; the live row wins for staged metadata, content fills the body.
95
+ const row = {
96
+ ...(content || {}),
97
+ ...rowState,
98
+ id,
99
+ briefingMarkdown:
100
+ firstNonEmpty(rowState.briefingMarkdown, content?.briefingMarkdown) || "",
101
+ bodyMarkdown: firstNonEmpty(rowState.bodyMarkdown, content?.briefingMarkdown, content?.bodyMarkdown) || "",
102
+ displayTitle: firstNonEmpty(rowState.displayTitle, rowState.title, content?.displayTitle, content?.title) || "Relay",
103
+ title: firstNonEmpty(rowState.title, content?.title, rowState.displayTitle) || "Relay",
104
+ // Do NOT default to the brand word "Relay" as if it were a person. When no real
105
+ // sender is staged, leave it empty so the per-kind seed builders (relay-briefing)
106
+ // apply the correct human-facing fallback ("Your agent" for the viewer's own
107
+ // agent). notifications.js already resolves this to "Your agent" or a real name
108
+ // before staging; this is only a last-resort merge default.
109
+ senderName: firstNonEmpty(rowState.senderName, content?.sender?.name, content?.sender?.handle) || "",
110
+ attachments: rowState.attachments || content?.attachments || [],
111
+ delivery: content?.delivery || rowState.delivery || null,
112
+ sender: content?.sender || (rowState.senderName ? { name: rowState.senderName } : null),
113
+ };
114
+
115
+ // When the row points at a task, fetch the verified task object so the per-kind
116
+ // seed builder can ground the session in the real question / result / objective
117
+ // and recent messages — not just the thin staged pointer. Best-effort: a fetch
118
+ // failure leaves row.task null and the briefing falls back to staged fields.
119
+ if (row.taskId) {
120
+ const task = await fetchTask(row.taskId, { log });
121
+ if (task) row.task = task;
122
+ }
123
+
124
+ // Render the per-kind seed once for the codex thread preview and as a coherent
125
+ // briefingMarkdown (which prefers the row, then the verified task, then an honest
126
+ // minimal fallback that never crashes). IMPORTANT: do NOT overwrite row.bodyMarkdown
127
+ // with the rendered seed — bodyMarkdown is a RAW content source that the seed
128
+ // builders read back (e.g. as the human_question fallback), so clobbering it makes
129
+ // the next render echo the prior seed into itself (a doubled body). briefingMarkdown
130
+ // is preview-only and is not read back by the seed builders, so it is safe to set.
131
+ row.briefingMarkdown = renderRelayRowBriefing(row);
132
+ return { row, rowState };
133
+ }
134
+
135
+ // Fetch the verified task object for a row that points at a task. The real API
136
+ // shape of GET /v1/tasks/:taskId (RelayClient.getTask) is `{ task: Task }` — a
137
+ // wrapper object, never a bare Task — so we unwrap response.task. Returns the
138
+ // unwrapped Task (or null on any failure / unexpected shape) so the open still
139
+ // proceeds with whatever the row carries.
140
+ async function fetchTask(taskId, { log = () => {} } = {}) {
141
+ if (!taskId) return null;
142
+ try {
143
+ const { RelayClient } = await import("./client.js");
144
+ const client = new RelayClient();
145
+ const response = await client.getTask(taskId);
146
+ const task = response && typeof response === "object" ? response.task : null;
147
+ return task && typeof task === "object" ? task : null;
148
+ } catch (error) {
149
+ log(`relay task fetch failed: ${error instanceof Error ? error.message : String(error)}`);
150
+ return null;
151
+ }
152
+ }
153
+
154
+ function resolveCwd() {
155
+ // The cloud companion has no per-row source cwd; open the host session in the
156
+ // user's home so it lands in a stable, writable working directory.
157
+ return process.env.RELAY_OPEN_CWD || os.homedir();
158
+ }
159
+
160
+ export async function openRelay({ id, host = "claude", log = () => {} } = {}) {
161
+ if (!id) throw new Error("openRelay requires a row id");
162
+ const cleanHost = String(host || "claude").toLowerCase();
163
+ const { row, rowState } = await resolveRow(id, { log });
164
+ return materializeRowInHost({ id, row, rowState, host: cleanHost, log });
165
+ }
166
+
167
+ // Materialize a resolved row into the foregrounded host (Codex thread or Claude
168
+ // native session). Shared by openRelay (staged companion row) and openTask
169
+ // (synthetic task-open row). Returns the CLI contract
170
+ // { id, host, url, openedInHost, skipExternalOpen }.
171
+ async function materializeRowInHost({ id, row, rowState = {}, host = "claude", log = () => {} }) {
172
+ const cleanHost = String(host || "claude").toLowerCase();
173
+ const cwd = resolveCwd();
174
+ // The human reads `visible`; the agent-only `operatorNote` (real ids + the tool
175
+ // call to make) rides a hidden channel per host (Claude: a system-meta transcript
176
+ // row; Codex: developerInstructions) so it never enters the visible transcript.
177
+ const { visible: briefing, operatorNote } = renderRelayRowSeed(row);
178
+ let url = null;
179
+ let openedInHost = false;
180
+ let skipExternalOpen = false;
181
+
182
+ if (cleanHost === "codex") {
183
+ let threadId = rowState.threadId || null;
184
+ let sessionPath = rowState.sessionPath || null;
185
+ if (!threadId || !codexRolloutExists(threadId)) {
186
+ const thread = await createCodexThread({ row, briefing, operatorNote, cwd });
187
+ threadId = thread.id;
188
+ sessionPath = thread.path;
189
+ rememberRow(id, {
190
+ threadId,
191
+ sessionPath,
192
+ title: row.displayTitle,
193
+ materializedSurfaces: {
194
+ codex: true,
195
+ claudeCode: Boolean(rowState.materializedSurfaces?.claudeCode || rowState.claudeNativeSession),
196
+ claudeCowork: Boolean(rowState.materializedSurfaces?.claudeCowork),
197
+ },
198
+ materializedAt: new Date().toISOString(),
199
+ });
200
+ }
201
+ ensureRelayCodexIndexMarker({ threadId, sessionPath, packetId: id });
202
+ finalizeCodexThreadState({
203
+ threadId,
204
+ title: row.displayTitle,
205
+ cwd,
206
+ preview: relayThreadPreview(row),
207
+ });
208
+ try {
209
+ setThreadPinned(threadId, true);
210
+ } catch {}
211
+ await waitForCodexOpenReadiness(threadId);
212
+ const desktopOpenResult = await refreshCodexDesktopForThreads([threadId], { force: true, openThreadId: threadId });
213
+ openedInHost = Boolean(desktopOpenResult?.ok);
214
+ skipExternalOpen = openedInHost;
215
+ url = `codex://threads/${encodeURIComponent(threadId)}`;
216
+ } else {
217
+ let claudeNativeSession = rowState.claudeNativeSession || null;
218
+ if (!isClaudeNativeSessionImported(claudeNativeSession)) {
219
+ const claude = materializeRowForClaude(row, { cwd, forceClaudeCode: true });
220
+ claudeNativeSession = claude.nativeSession || null;
221
+ const previousSurfaces = rowState.materializedSurfaces || {};
222
+ rememberRow(id, {
223
+ claudeMarkdownPaths: claude.paths,
224
+ claudeNativeSession,
225
+ materializedSurfaces: {
226
+ codex: Boolean(previousSurfaces.codex || rowState.threadId),
227
+ claudeCode: Boolean(previousSurfaces.claudeCode || claude.nativeSession || claude.surfaces?.claudeCode),
228
+ claudeCowork: Boolean(previousSurfaces.claudeCowork || claude.surfaces?.claudeCowork),
229
+ },
230
+ materializedAt: new Date().toISOString(),
231
+ title: row.displayTitle,
232
+ });
233
+ } else {
234
+ claudeNativeSession = ensureClaudeDesktopImported(claudeNativeSession, {
235
+ title: relayRowTitle(row),
236
+ cwd,
237
+ createdAt: row.createdAt,
238
+ });
239
+ rememberRow(id, { claudeNativeSession });
240
+ }
241
+ url = claudeNativeSession?.deepLink || claudeNativeSession?.desktopImport?.deepLink || null;
242
+ }
243
+
244
+ return { id, host: cleanHost, url, openedInHost, skipExternalOpen };
245
+ }
246
+
247
+ // `relay open --task <taskId> --host <host>` — materialize a Relay task into a
248
+ // visible native agent session (exactly like a staged relay row). The task object
249
+ // (GET /v1/tasks/:id, unwrapped) carries title/objective/state but NO messages or
250
+ // results, so the seed instructs the materialized session to call
251
+ // relay_task_status(taskId) for live detail. Works the same for active and
252
+ // completed tasks. The synthetic row caches under packets[`task:<taskId>`] and
253
+ // carries no `direction` field, so the overlay pill (which filters
254
+ // direction==='inbound') never lists it.
255
+ export async function openTask({ taskId, host = "claude", log = () => {} } = {}) {
256
+ if (!taskId) throw new Error("openTask requires a taskId");
257
+ const task = await fetchTask(taskId, { log });
258
+ const id = `task:${taskId}`;
259
+ const rowState = getRowState(id) || {};
260
+ const taskTitle = (task && task.title) || "Relay task";
261
+ const row = {
262
+ id,
263
+ taskId,
264
+ relayNotificationKind: "task_open",
265
+ senderName: "Relay task",
266
+ title: taskTitle,
267
+ displayTitle: `🔁 Task: ${task?.title || taskId}`,
268
+ task: task || null,
269
+ createdAt: task?.updatedAt || task?.createdAt || "",
270
+ };
271
+ row.briefingMarkdown = row.bodyMarkdown = renderTaskOpenBriefing(task, taskId);
272
+ return materializeRowInHost({ id, row, rowState, host, log });
273
+ }
274
+
275
+ async function waitForCodexOpenReadiness(threadId) {
276
+ const deadline = Date.now() + Number(process.env.RELAY_CODEX_ROW_TIMEOUT_MS || 6000);
277
+ while (!codexThreadRowExists(threadId) && Date.now() < deadline) {
278
+ await sleep(150);
279
+ }
280
+ const settleMs = Number(process.env.RELAY_CODEX_OPEN_SETTLE_MS || 250);
281
+ if (settleMs > 0) await sleep(settleMs);
282
+ }
283
+
284
+ function codexRolloutExists(threadId) {
285
+ if (!threadId) return false;
286
+ try {
287
+ const sessionPath = findCodexSessionPath(threadId);
288
+ return Boolean(sessionPath && fs.existsSync(sessionPath));
289
+ } catch {
290
+ return false;
291
+ }
292
+ }
293
+
294
+ async function createCodexThread({ row, briefing, operatorNote = "", cwd }) {
295
+ return withCodexAppServer(async (client) => {
296
+ // developerInstructions is Codex's hidden, non-rendered channel. The base
297
+ // framing plus the agent-only operatorNote (real ids + the tool call) live here
298
+ // so the human-visible transcript stays clean — only `briefing` (the visible
299
+ // body) is appended as the assistant turn below.
300
+ const developerInstructions = [
301
+ "This thread was materialized by Relay Companion. The first visible content is one Relay message authored by the sending agent. It may be a plain note, a question, or a context handoff; treat it according to its content unless the local user asks otherwise.",
302
+ String(operatorNote || "").trim(),
303
+ ]
304
+ .filter(Boolean)
305
+ .join("\n\n");
306
+ const started = await client.request("thread/start", {
307
+ cwd,
308
+ approvalPolicy: "never",
309
+ sandbox: "danger-full-access",
310
+ threadSource: "user",
311
+ developerInstructions,
312
+ });
313
+ const threadId = started.thread.id;
314
+ const sessionPath = started.thread.path || findCodexSessionPath(threadId);
315
+ await client.request("thread/name/set", { threadId, name: row.displayTitle });
316
+ appendVisibleAssistantTurn({ sessionPath, text: briefing, cwd });
317
+ const deadline = Date.now() + Number(process.env.RELAY_CODEX_ROW_TIMEOUT_MS || 6000);
318
+ while (!codexThreadRowExists(threadId) && Date.now() < deadline) {
319
+ await sleep(150);
320
+ }
321
+ finalizeCodexThreadState({
322
+ threadId,
323
+ title: row.displayTitle,
324
+ cwd,
325
+ preview: relayThreadPreview(row),
326
+ });
327
+ await client.request("thread/name/set", { threadId, name: row.displayTitle });
328
+ return { id: threadId, cwd, path: sessionPath, rowPersisted: codexThreadRowExists(threadId) };
329
+ });
330
+ }
331
+
332
+ function ensureRelayCodexIndexMarker({ threadId, sessionPath, packetId }) {
333
+ const resolvedSessionPath = sessionPath || findCodexSessionPath(threadId);
334
+ if (!resolvedSessionPath) return null;
335
+ try {
336
+ return ensureCodexThreadIndexMarker({ sessionPath: resolvedSessionPath, markerId: packetId || threadId });
337
+ } catch {
338
+ return null;
339
+ }
340
+ }
341
+
342
+ async function refreshCodexDesktopForThreads(threadIds, { force = false, openThreadId = null } = {}) {
343
+ const uniqueThreadIds = Array.from(new Set(threadIds.filter(Boolean)));
344
+ if (!uniqueThreadIds.length && !force) return null;
345
+ try {
346
+ const result = await notifyCodexDesktopThreads({
347
+ threadIds: uniqueThreadIds,
348
+ pinnedThreadIds: readPinnedThreadIds(),
349
+ openThreadId,
350
+ });
351
+ if (process.env.RELAY_DEBUG && result.attempted && !result.ok) {
352
+ console.error(`Relay Codex desktop refresh did not reach a running window: ${JSON.stringify(result)}`);
353
+ }
354
+ return result;
355
+ } catch (error) {
356
+ if (process.env.RELAY_DEBUG) {
357
+ console.error(`Relay Codex desktop refresh failed: ${error instanceof Error ? error.message : String(error)}`);
358
+ }
359
+ return null;
360
+ }
361
+ }
362
+
363
+ // Exposed so a `relay repair claude` style command (or tests) can drive the
364
+ // Claude Desktop title repair pass, mirroring the original.
365
+ export function repairClaudeTitles() {
366
+ return repairClaudeDesktopRelaySessions();
367
+ }
368
+
369
+ function firstNonEmpty(...values) {
370
+ for (const value of values) {
371
+ if (String(value || "").trim()) return value;
372
+ }
373
+ return "";
374
+ }
375
+
376
+ function sleep(ms) {
377
+ return new Promise((resolve) => setTimeout(resolve, ms));
378
+ }