relay-companion 0.1.30 → 0.1.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -108,6 +108,7 @@ async function resolveRow(id, { log = () => {} } = {}) {
108
108
  // before staging; this is only a last-resort merge default.
109
109
  senderName: firstNonEmpty(rowState.senderName, content?.sender?.name, content?.sender?.handle) || "",
110
110
  attachments: rowState.attachments || content?.attachments || [],
111
+ attachmentUrls: rowState.attachmentUrls || content?.attachmentUrls || {},
111
112
  delivery: content?.delivery || rowState.delivery || null,
112
113
  sender: content?.sender || (rowState.senderName ? { name: rowState.senderName } : null),
113
114
  };
@@ -213,10 +214,14 @@ export async function openRelay({ id, host = "claude", log = () => {} } = {}) {
213
214
  async function materializeRowInHost({ id, row, rowState = {}, host = "claude", log = () => {} }) {
214
215
  const cleanHost = String(host || "claude").toLowerCase();
215
216
  const cwd = resolveCwd();
217
+ const rowWithAttachments = await materializeAttachmentFiles(row, { log });
218
+ const materializationSignature = rowMaterializationSignature(rowWithAttachments);
219
+ const materializationStale =
220
+ hasAttachments(rowWithAttachments) && rowState.attachmentMaterializationSignature !== materializationSignature;
216
221
  // The human reads `visible`; the agent-only `operatorNote` (real ids + the tool
217
222
  // call to make) rides a hidden channel per host (Claude: a system-meta transcript
218
223
  // row; Codex: developerInstructions) so it never enters the visible transcript.
219
- const { visible: briefing, operatorNote } = renderRelayRowSeed(row);
224
+ const { visible: briefing, operatorNote } = renderRelayRowSeed(rowWithAttachments);
220
225
  let url = null;
221
226
  let openedInHost = false;
222
227
  let skipExternalOpen = false;
@@ -224,14 +229,20 @@ async function materializeRowInHost({ id, row, rowState = {}, host = "claude", l
224
229
  if (cleanHost === "codex") {
225
230
  let threadId = rowState.threadId || null;
226
231
  let sessionPath = rowState.sessionPath || null;
232
+ if (materializationStale) {
233
+ threadId = null;
234
+ sessionPath = null;
235
+ }
227
236
  if (!threadId || !codexRolloutExists(threadId)) {
228
- const thread = await createCodexThread({ row, briefing, operatorNote, cwd });
237
+ const thread = await createCodexThread({ row: rowWithAttachments, briefing, operatorNote, cwd });
229
238
  threadId = thread.id;
230
239
  sessionPath = thread.path;
231
240
  rememberRow(id, {
232
241
  threadId,
233
242
  sessionPath,
234
- title: row.displayTitle,
243
+ title: rowWithAttachments.displayTitle,
244
+ attachments: rowWithAttachments.attachments || [],
245
+ attachmentMaterializationSignature: materializationSignature,
235
246
  materializedSurfaces: {
236
247
  codex: true,
237
248
  claudeCode: Boolean(rowState.materializedSurfaces?.claudeCode || rowState.claudeNativeSession),
@@ -243,9 +254,9 @@ async function materializeRowInHost({ id, row, rowState = {}, host = "claude", l
243
254
  ensureRelayCodexIndexMarker({ threadId, sessionPath, packetId: id });
244
255
  finalizeCodexThreadState({
245
256
  threadId,
246
- title: row.displayTitle,
257
+ title: rowWithAttachments.displayTitle,
247
258
  cwd,
248
- preview: relayThreadPreview(row),
259
+ preview: relayThreadPreview(rowWithAttachments),
249
260
  });
250
261
  try {
251
262
  setThreadPinned(threadId, true);
@@ -275,26 +286,29 @@ async function materializeRowInHost({ id, row, rowState = {}, host = "claude", l
275
286
  url = `codex://threads/${encodeURIComponent(threadId)}`;
276
287
  } else {
277
288
  let claudeNativeSession = rowState.claudeNativeSession || null;
289
+ if (materializationStale) claudeNativeSession = null;
278
290
  if (!isClaudeNativeSessionImported(claudeNativeSession)) {
279
- const claude = materializeRowForClaude(row, { cwd, forceClaudeCode: true });
291
+ const claude = materializeRowForClaude(rowWithAttachments, { cwd, forceClaudeCode: true });
280
292
  claudeNativeSession = claude.nativeSession || null;
281
293
  const previousSurfaces = rowState.materializedSurfaces || {};
282
294
  rememberRow(id, {
283
295
  claudeMarkdownPaths: claude.paths,
284
296
  claudeNativeSession,
297
+ attachments: rowWithAttachments.attachments || [],
298
+ attachmentMaterializationSignature: materializationSignature,
285
299
  materializedSurfaces: {
286
300
  codex: Boolean(previousSurfaces.codex || rowState.threadId),
287
301
  claudeCode: Boolean(previousSurfaces.claudeCode || claude.nativeSession || claude.surfaces?.claudeCode),
288
302
  claudeCowork: Boolean(previousSurfaces.claudeCowork || claude.surfaces?.claudeCowork),
289
303
  },
290
304
  materializedAt: new Date().toISOString(),
291
- title: row.displayTitle,
305
+ title: rowWithAttachments.displayTitle,
292
306
  });
293
307
  } else {
294
308
  claudeNativeSession = ensureClaudeDesktopImported(claudeNativeSession, {
295
- title: relayRowTitle(row),
309
+ title: relayRowTitle(rowWithAttachments),
296
310
  cwd,
297
- createdAt: row.createdAt,
311
+ createdAt: rowWithAttachments.createdAt,
298
312
  });
299
313
  rememberRow(id, { claudeNativeSession });
300
314
  }
@@ -304,6 +318,91 @@ async function materializeRowInHost({ id, row, rowState = {}, host = "claude", l
304
318
  return { id, host: cleanHost, url, openedInHost, skipExternalOpen };
305
319
  }
306
320
 
321
+ async function materializeAttachmentFiles(row, { log = () => {} } = {}) {
322
+ const attachments = Array.isArray(row?.attachments) ? row.attachments : [];
323
+ if (!attachments.length) return row;
324
+ const attachmentUrls = row?.attachmentUrls && typeof row.attachmentUrls === "object" ? row.attachmentUrls : {};
325
+ const dir = path.join(storeDir(), "attachments", safeFileStem(row?.id || row?.relayId || "relay"));
326
+ const materialized = [];
327
+ for (const [index, attachment] of attachments.entries()) {
328
+ if (!attachment || typeof attachment !== "object") continue;
329
+ const name = String(attachment.name || attachment.filename || `attachment-${index + 1}`).trim() || `attachment-${index + 1}`;
330
+ const url = String(attachmentUrls[attachment.id] || attachment.openUrl || "").trim();
331
+ const localPath = await ensureAttachmentLocalCopy({ attachment, url, dir, name, index, log });
332
+ materialized.push({
333
+ ...attachment,
334
+ name,
335
+ filename: name,
336
+ openUrl: url || attachment.openUrl,
337
+ ...(localPath ? { localPath } : {}),
338
+ });
339
+ }
340
+ return { ...row, attachments: materialized };
341
+ }
342
+
343
+ async function ensureAttachmentLocalCopy({ attachment, url, dir, name, index, log }) {
344
+ const existing = String(attachment.localPath || "").trim();
345
+ if (existing && fs.existsSync(existing) && sizeMatches(existing, attachment.bytes)) return existing;
346
+ if (!url) return existing || "";
347
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
348
+ const filePath = path.join(dir, attachmentFileName(attachment, name, index));
349
+ if (fs.existsSync(filePath) && sizeMatches(filePath, attachment.bytes)) return filePath;
350
+ try {
351
+ const res = await fetch(url);
352
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
353
+ const body = Buffer.from(await res.arrayBuffer());
354
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
355
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
356
+ fs.renameSync(tmp, filePath);
357
+ return filePath;
358
+ } catch (error) {
359
+ log(`relay attachment download failed for ${name}: ${error instanceof Error ? error.message : String(error)}`);
360
+ return existing || "";
361
+ }
362
+ }
363
+
364
+ function sizeMatches(filePath, expectedBytes) {
365
+ if (!Number.isFinite(Number(expectedBytes))) return true;
366
+ try {
367
+ return fs.statSync(filePath).size === Number(expectedBytes);
368
+ } catch {
369
+ return false;
370
+ }
371
+ }
372
+
373
+ function attachmentFileName(attachment, name, index) {
374
+ const prefix = safeFileStem(attachment.id || `attachment-${index + 1}`);
375
+ const clean = safeFilename(name);
376
+ return `${prefix}-${clean}`;
377
+ }
378
+
379
+ function safeFilename(value) {
380
+ return String(value || "attachment")
381
+ .replace(/[/\\?%*:|"<>]+/g, "-")
382
+ .replace(/\s+/g, " ")
383
+ .trim()
384
+ .slice(0, 160) || "attachment";
385
+ }
386
+
387
+ function rowMaterializationSignature(row) {
388
+ const attachments = Array.isArray(row?.attachments) ? row.attachments : [];
389
+ return JSON.stringify({
390
+ renderer: "relay-attachments-table-v2",
391
+ body: String(row?.bodyMarkdown || row?.briefingMarkdown || ""),
392
+ attachments: attachments.map((attachment) => ({
393
+ id: attachment.id || "",
394
+ name: attachment.name || attachment.filename || "",
395
+ bytes: attachment.bytes || 0,
396
+ sha256: attachment.sha256 || "",
397
+ localPath: attachment.localPath || "",
398
+ })),
399
+ });
400
+ }
401
+
402
+ function hasAttachments(row) {
403
+ return Array.isArray(row?.attachments) && row.attachments.length > 0;
404
+ }
405
+
307
406
  // `relay open --task <taskId> --host <host>` — materialize a Relay task into a
308
407
  // visible native agent session (exactly like a staged relay row). The task object
309
408
  // (GET /v1/tasks/:id, unwrapped) carries title/objective/state but NO messages or
@@ -434,6 +533,10 @@ function firstNonEmpty(...values) {
434
533
  return "";
435
534
  }
436
535
 
536
+ function safeFileStem(value) {
537
+ return String(value || new Date().toISOString()).replace(/[^0-9A-Za-z._-]+/g, "-").replace(/^-|-$/g, "");
538
+ }
539
+
437
540
  function sleep(ms) {
438
541
  return new Promise((resolve) => setTimeout(resolve, ms));
439
542
  }
@@ -66,6 +66,7 @@ export function renderRelayRowSeed(row) {
66
66
  seed = renderMessageSeed(row, task);
67
67
  }
68
68
  let visible = String(seed?.visible || "").trim();
69
+ visible = insertAttachmentSection(visible, row);
69
70
  const operatorNote = String(seed?.operatorNote || "").trim();
70
71
  if (!visible) visible = renderMinimalSeed(row);
71
72
  return { visible, operatorNote };
@@ -243,6 +244,54 @@ function renderMinimalSeed(row) {
243
244
  return joinSections([`# ${title}`, "No content was available to seed this session."]);
244
245
  }
245
246
 
247
+ function insertAttachmentSection(visible, row) {
248
+ const attachments = Array.isArray(row?.attachments) ? row.attachments : [];
249
+ const rendered = attachments.map(renderAttachmentTableRow).filter(Boolean);
250
+ if (!rendered.length) return visible;
251
+ const section = ["## Attachments", "", "| File | Size |", "| --- | ---: |", ...rendered].join("\n");
252
+ const cleanVisible = String(visible || "").trim();
253
+ const lines = cleanVisible.split("\n");
254
+ if (/^#\s+/.test(lines[0] || "")) {
255
+ const title = lines.shift();
256
+ return joinSections([title, section, lines.join("\n")]);
257
+ }
258
+ return joinSections([section, cleanVisible]);
259
+ }
260
+
261
+ function renderAttachmentTableRow(attachment) {
262
+ if (!attachment || typeof attachment !== "object") return "";
263
+ const name = String(attachment.name || attachment.filename || "attachment").trim() || "attachment";
264
+ const size = humanBytes(attachment.bytes);
265
+ const localPath = String(attachment.localPath || "").trim();
266
+ const openUrl = String(attachment.openUrl || "").trim();
267
+ const href = localPath || openUrl;
268
+ const label = escapeTableCell(name);
269
+ const file = href
270
+ ? `[${escapeMarkdownLinkText(label)}](<${escapeMarkdownHref(href)}>)`
271
+ : label;
272
+ return `| ${file} | ${escapeTableCell(size || "-")} |`;
273
+ }
274
+
275
+ function humanBytes(bytes) {
276
+ const n = Number(bytes);
277
+ if (!Number.isFinite(n) || n <= 0) return "";
278
+ if (n < 1024) return `${n} B`;
279
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`;
280
+ return `${(n / (1024 * 1024)).toFixed(n < 10 * 1024 * 1024 ? 1 : 0)} MB`;
281
+ }
282
+
283
+ function escapeMarkdownLinkText(value) {
284
+ return String(value).replace(/[[\]\\]/g, "\\$&");
285
+ }
286
+
287
+ function escapeMarkdownHref(value) {
288
+ return String(value).replace(/[<>]/g, encodeURIComponent);
289
+ }
290
+
291
+ function escapeTableCell(value) {
292
+ return String(value).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
293
+ }
294
+
246
295
  function relayKind(row) {
247
296
  return String(row?.relayNotificationKind || row?.kind || "").trim().toLowerCase();
248
297
  }