relay-companion 0.1.8 → 0.1.10

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.
@@ -159,7 +159,12 @@
159
159
  transition:opacity .3s var(--settle) .06s;
160
160
  }
161
161
  .card.collapsed .scroll { opacity:0; pointer-events:none; transition:opacity .14s var(--settle); }
162
- .card.peek .scroll { opacity:1; pointer-events:auto; }
162
+ .card.peek .scroll {
163
+ opacity:1; pointer-events:auto;
164
+ /* safety net: anything beyond the sized first row fades instead of hard-clipping */
165
+ -webkit-mask-image:linear-gradient(to bottom, #000 calc(100% - 12px), transparent);
166
+ mask-image:linear-gradient(to bottom, #000 calc(100% - 12px), transparent);
167
+ }
163
168
  .scroll::-webkit-scrollbar { width:10px; }
164
169
  .scroll::-webkit-scrollbar-thumb { background:rgba(31,26,23,.12); border-radius:10px; border:3px solid var(--bg); background-clip:padding-box; }
165
170
  .scroll::-webkit-scrollbar-thumb:hover { background:rgba(31,26,23,.2); background-clip:padding-box; }
@@ -299,6 +304,40 @@
299
304
  .task-meta .sep { color:var(--muted-3); }
300
305
  .task-approve { color:var(--accent); font-weight:600; }
301
306
 
307
+ /* ---- task detail (in-pill) ---- */
308
+ .td-head { display:flex; align-items:center; gap:8px; padding:10px 16px 2px; }
309
+ .td-back {
310
+ appearance:none; border:0; background:transparent; cursor:pointer; padding:2px 6px 2px 0;
311
+ font-family:var(--sans); font-size:12.5px; font-weight:500; color:var(--muted-2);
312
+ transition:color .15s var(--settle);
313
+ }
314
+ .td-back:hover { color:var(--ink); }
315
+ .td-title { font-family:var(--serif); font-size:16px; font-weight:500; color:var(--ink); padding:2px 16px 0; line-height:1.3; }
316
+ .td-meta { display:flex; align-items:center; gap:7px; padding:6px 16px 10px; font-size:11.5px; color:var(--muted-2); flex-wrap:wrap; }
317
+ .td-state { font-weight:500; }
318
+ .td-state.s-active { color:var(--accent); }
319
+ .td-section {
320
+ font-family:var(--serif); font-size:12.5px; font-weight:500; color:var(--muted-2);
321
+ padding:12px 16px 5px; border-top:1px solid var(--hair);
322
+ }
323
+ .td-block { padding:0 16px 10px; font-size:12px; line-height:1.5; color:var(--ink-2); }
324
+ .td-block .quiet { color:var(--muted-2); }
325
+ .td-msg { padding:8px 16px 10px; border-bottom:1px solid var(--hair-2); }
326
+ .td-msg:last-child { border-bottom:0; }
327
+ .td-msg-top { display:flex; align-items:baseline; gap:7px; }
328
+ .td-msg-sender { font-size:11.5px; font-weight:600; color:var(--ink); }
329
+ .td-msg-kind { font-size:10.5px; color:var(--muted-3); }
330
+ .td-msg-time { margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--muted-2); }
331
+ .td-msg-body { font-size:12px; line-height:1.5; color:var(--muted); margin-top:3px; white-space:pre-line; }
332
+ .td-part { display:flex; align-items:baseline; gap:8px; padding:5px 16px; font-size:12px; }
333
+ .td-part-name { color:var(--ink); font-weight:500; }
334
+ .td-part-state { margin-left:auto; font-size:11px; color:var(--muted-2); }
335
+ .td-result { padding:6px 16px 10px; }
336
+ .td-result-title { font-size:12.5px; font-weight:600; color:var(--ink); }
337
+ .td-result-body { font-size:12px; line-height:1.5; color:var(--muted); margin-top:3px; white-space:pre-line; }
338
+ .td-actions { display:flex; gap:16px; padding:10px 16px 14px; align-items:center; border-top:1px solid var(--hair); }
339
+ .td-err { color:#b4332a; font-size:11.5px; padding:8px 16px; }
340
+
302
341
  /* ---- empty states ---- */
303
342
  .empty {
304
343
  height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center;
@@ -477,6 +516,9 @@
477
516
  </div>
478
517
  </section>
479
518
 
519
+ <!-- Task detail view (in-pill mirror of the web task page, live-polled) -->
520
+ <section class="view hidden" id="taskDetailView"></section>
521
+
480
522
  <!-- Contacts view -->
481
523
  <section class="view hidden" id="contactsView">
482
524
  <div class="cv-head">
@@ -524,6 +566,7 @@
524
566
  const tasksListEl = document.getElementById("tasksList");
525
567
  const tasksEmptyEl = document.getElementById("tasksEmpty");
526
568
  const tasksBadgeEl = document.getElementById("tasksBadge");
569
+ const taskDetailViewEl = document.getElementById("taskDetailView");
527
570
  const contactsViewEl = document.getElementById("contactsView");
528
571
  const cvAddEl = document.getElementById("cvAdd");
529
572
  const cvListEl = document.getElementById("cvList");
@@ -664,7 +707,6 @@
664
707
  clearPeek();
665
708
  collapsed = false;
666
709
  cardEl.classList.remove("collapsed", "offstage", "bye");
667
- snapSize(PEEK.w, PEEK.h);
668
710
  ghost = true; peeking = true;
669
711
  cardEl.classList.add("peek", "ghost");
670
712
  const kind = row && row.relayNotificationKind;
@@ -673,6 +715,8 @@
673
715
  applyView();
674
716
  renderAll();
675
717
  scrollEl.scrollTop = 0;
718
+ // Size to the rendered first row (no pill morph — the banner just appears at size).
719
+ snapSize(PEEK.w, peekHeight());
676
720
  playTink();
677
721
  // Leave after ~7s — but never rug-pull someone mid-reply or with the pointer on
678
722
  // the banner; check again shortly instead (macOS banners hold while hovered too).
@@ -705,6 +749,13 @@
705
749
  renderAll();
706
750
  playTink();
707
751
  }
752
+ // The banner must show its WHOLE first row — a clipped preview reads as broken.
753
+ // Measure after render; QR question rows are tall, so allow room before capping.
754
+ function peekHeight() {
755
+ const first = scrollEl.querySelector(".row, .task");
756
+ const rowH = first ? first.offsetHeight : 74;
757
+ return Math.min(44 + rowH + 6, 340);
758
+ }
708
759
  // A new relay arrived. If folded, peek open like an iOS banner (~7s) then fold back.
709
760
  // The peek routes to the tab the item lives in: task_request / share_approval -> Tasks
710
761
  // (the clean split), everything else -> Relays.
@@ -721,7 +772,7 @@
721
772
  applyView();
722
773
  renderAll();
723
774
  scrollEl.scrollTop = 0;
724
- springTo(PEEK.w, PEEK.h);
775
+ springTo(PEEK.w, peekHeight());
725
776
  playTink();
726
777
  peekTimer = setTimeout(() => {
727
778
  peekTimer = null; peeking = false;
@@ -1058,6 +1109,14 @@
1058
1109
  function relayActionsHtml(r) {
1059
1110
  const k = r.relayNotificationKind;
1060
1111
  if (k === "human_question") {
1112
+ // Once the question is answered (here, on the web, or inside a host session),
1113
+ // show what was sent instead of a live reply box that could double-answer.
1114
+ if (r.humanAnswered) {
1115
+ const sent = r.humanAnswerMarkdown ? firstLine(r.humanAnswerMarkdown, 140) : "";
1116
+ return sent
1117
+ ? `<div class="row-note">You replied: “${esc(sent)}” — the agent run has resumed.</div>`
1118
+ : `<div class="row-note">Answered — the agent run has resumed.</div>`;
1119
+ }
1061
1120
  // relay_answer_human_question requires BOTH taskId and messageId (mcp.js). Only render the
1062
1121
  // inline reply box when both are present; otherwise the box would post a dead answer.
1063
1122
  if (r.quickReply && r.taskId && r.messageId) {
@@ -1156,7 +1215,7 @@
1156
1215
  const text = ta ? ta.value.trim() : "";
1157
1216
  if (!text) { showRowError(id, "Write an answer first."); return; }
1158
1217
  runRowAction(id, btn, async () => {
1159
- const res = await window.relay.answer(r.taskId, r.messageId, text);
1218
+ const res = await window.relay.answer(r.taskId, r.messageId, text, r.id);
1160
1219
  if (res && res.ok) { if (ta) ta.value = ""; }
1161
1220
  return res;
1162
1221
  });
@@ -1214,6 +1273,168 @@
1214
1273
  }
1215
1274
  }
1216
1275
 
1276
+ // ---------- in-pill task detail (live) ----------
1277
+ // Clicking a task row opens a live detail panel INSIDE the pill (the same data the
1278
+ // web task page renders), polled every 5s while open. "Open in Claude/Codex" keeps
1279
+ // the old materialize-into-host behavior as an explicit action.
1280
+ let taskDetailId = null;
1281
+ let taskDetailData = null;
1282
+ let taskDetailError = "";
1283
+ let taskDetailTimer = null;
1284
+ function showTaskDetail(taskId) {
1285
+ taskDetailId = taskId;
1286
+ taskDetailData = (payload.tasks || []).find((t) => t.id === taskId) || null; // summary while loading
1287
+ taskDetailError = "";
1288
+ activeView = "taskDetail";
1289
+ syncTabs();
1290
+ applyView();
1291
+ renderTaskDetail();
1292
+ refreshTaskDetail();
1293
+ if (taskDetailTimer) clearInterval(taskDetailTimer);
1294
+ taskDetailTimer = setInterval(() => {
1295
+ if (activeView === "taskDetail" && !collapsed) refreshTaskDetail();
1296
+ }, 5000);
1297
+ }
1298
+ function closeTaskDetail() {
1299
+ if (taskDetailTimer) { clearInterval(taskDetailTimer); taskDetailTimer = null; }
1300
+ taskDetailId = null;
1301
+ taskDetailData = null;
1302
+ activeView = "tasks";
1303
+ syncTabs();
1304
+ applyView();
1305
+ renderAll();
1306
+ }
1307
+ async function refreshTaskDetail() {
1308
+ if (!taskDetailId) return;
1309
+ const id = taskDetailId;
1310
+ try {
1311
+ const res = await window.relay.taskStatus(id);
1312
+ if (id !== taskDetailId) return; // navigated away while fetching
1313
+ if (res && res.ok && res.task) {
1314
+ taskDetailData = res.task;
1315
+ taskDetailError = "";
1316
+ } else {
1317
+ taskDetailError = (res && res.error) || "Could not load the task.";
1318
+ }
1319
+ } catch (err) {
1320
+ if (id !== taskDetailId) return;
1321
+ taskDetailError = (err && err.message) || "Could not load the task.";
1322
+ }
1323
+ if (activeView === "taskDetail") renderTaskDetail();
1324
+ }
1325
+ function tdChip(state) {
1326
+ const cls = state === "active" || state === "completing" ? "s-active" : "";
1327
+ return `<span class="td-state ${cls}">${esc(taskStateLabel(state))}</span>`;
1328
+ }
1329
+ function renderTaskDetail() {
1330
+ if (!taskDetailViewEl) return;
1331
+ const t = taskDetailData;
1332
+ if (!t) {
1333
+ taskDetailViewEl.innerHTML = `
1334
+ <div class="td-head"><button class="td-back" type="button" data-td-back="1">‹ Tasks</button></div>
1335
+ <div class="td-block quiet">${taskDetailError ? esc(taskDetailError) : "Loading task…"}</div>`;
1336
+ wireTaskDetail();
1337
+ return;
1338
+ }
1339
+ const participants = Array.isArray(t.participants) ? t.participants : [];
1340
+ const messages = Array.isArray(t.recentMessages) ? t.recentMessages.slice(-5) : [];
1341
+ const results = Array.isArray(t.results) ? t.results : [];
1342
+ const approvals = Array.isArray(t.pendingApprovals) ? t.pendingApprovals : [];
1343
+ const objective = String(t.objective || "").trim();
1344
+
1345
+ let html = `
1346
+ <div class="td-head">
1347
+ <button class="td-back" type="button" data-td-back="1">‹ Tasks</button>
1348
+ <span class="task-time">${esc(timeAgo(t.updatedAt))}</span>
1349
+ </div>
1350
+ <div class="td-title">${esc(t.title || "Untitled task")}</div>
1351
+ <div class="td-meta">${tdChip(t.state)}<span class="sep">·</span><span>${esc(participantSummary(t))}</span></div>`;
1352
+ if (objective) html += `<div class="td-block quiet">${esc(firstLine(objective, 160))}</div>`;
1353
+ if (taskDetailError) html += `<div class="td-err">${esc(taskDetailError)}</div>`;
1354
+
1355
+ if (approvals.length) {
1356
+ html += `<div class="td-section">Waiting on you</div>`;
1357
+ for (const a of approvals) {
1358
+ const preview = firstLine(a.payloadMarkdown || a.bodyMarkdown || "an outbound share", 110);
1359
+ html += `
1360
+ <div class="td-block">
1361
+ ${esc(preview)}
1362
+ <div class="rk-actions" style="margin-top:8px">
1363
+ <button class="act-btn accept" type="button" data-td-approve="${esc(a.id)}">Approve</button>
1364
+ <button class="act-btn decline" type="button" data-td-decline="${esc(a.id)}">Decline</button>
1365
+ </div>
1366
+ </div>`;
1367
+ }
1368
+ }
1369
+ if (results.length) {
1370
+ html += `<div class="td-section">Results visible to you</div>`;
1371
+ for (const r of results) {
1372
+ html += `
1373
+ <div class="td-result">
1374
+ <div class="td-result-title">${esc(r.title || "Result")}</div>
1375
+ <div class="td-result-body">${esc(firstLine(r.bodyMarkdown || "", 400))}</div>
1376
+ </div>`;
1377
+ }
1378
+ }
1379
+ if (messages.length) {
1380
+ html += `<div class="td-section">Messages</div>`;
1381
+ for (const m of messages) {
1382
+ html += `
1383
+ <div class="td-msg">
1384
+ <div class="td-msg-top">
1385
+ <span class="td-msg-sender">${esc(m.senderLabel || "Agent")}</span>
1386
+ <span class="td-msg-kind">${esc(String(m.kind || "").replaceAll("_", " "))}</span>
1387
+ <span class="td-msg-time">${esc(timeAgo(m.createdAt))}</span>
1388
+ </div>
1389
+ <div class="td-msg-body">${esc(firstLine(m.bodyMarkdown || "", 220))}</div>
1390
+ </div>`;
1391
+ }
1392
+ }
1393
+ if (participants.length) {
1394
+ html += `<div class="td-section">Participants</div>`;
1395
+ for (const p of participants) {
1396
+ html += `
1397
+ <div class="td-part">
1398
+ <span class="td-part-name">${esc(p.displayName || p.email || "Participant")}</span>
1399
+ <span class="td-part-state">${esc(String(p.invitationState || "").replaceAll("_", " "))}</span>
1400
+ </div>`;
1401
+ }
1402
+ }
1403
+ html += `
1404
+ <div class="td-actions" data-stop="1">
1405
+ <button class="act-btn accept" type="button" data-td-open="${esc(t.id)}">Open in Claude or Codex</button>
1406
+ </div>`;
1407
+ taskDetailViewEl.innerHTML = html;
1408
+ wireTaskDetail();
1409
+ }
1410
+ async function runTdMutation(btn, fn) {
1411
+ if (!btn || btn.disabled) return;
1412
+ btn.disabled = true;
1413
+ try {
1414
+ const res = await fn();
1415
+ if (res && res.ok === false) taskDetailError = res.error || "Could not complete that.";
1416
+ } catch (err) {
1417
+ taskDetailError = (err && err.message) || "Could not complete that.";
1418
+ } finally {
1419
+ btn.disabled = false;
1420
+ refreshTaskDetail();
1421
+ }
1422
+ }
1423
+ function wireTaskDetail() {
1424
+ for (const btn of taskDetailViewEl.querySelectorAll("[data-td-back]")) {
1425
+ btn.addEventListener("click", closeTaskDetail);
1426
+ }
1427
+ for (const btn of taskDetailViewEl.querySelectorAll("[data-td-open]")) {
1428
+ btn.addEventListener("click", () => window.relay.openTask(btn.getAttribute("data-td-open")));
1429
+ }
1430
+ for (const btn of taskDetailViewEl.querySelectorAll("[data-td-approve]")) {
1431
+ btn.addEventListener("click", () => runTdMutation(btn, () => window.relay.approve(taskDetailId, btn.getAttribute("data-td-approve"))));
1432
+ }
1433
+ for (const btn of taskDetailViewEl.querySelectorAll("[data-td-decline]")) {
1434
+ btn.addEventListener("click", () => runTdMutation(btn, () => window.relay.decline(taskDetailId, btn.getAttribute("data-td-decline"))));
1435
+ }
1436
+ }
1437
+
1217
1438
  function wireTaskRows() {
1218
1439
  // action zones inside a row must not also open the task web detail
1219
1440
  for (const z of tasksListEl.querySelectorAll('[data-stop="1"]')) {
@@ -1235,15 +1456,17 @@
1235
1456
  runTaskAction(btn, () => window.relay.reject(taskId, pid));
1236
1457
  });
1237
1458
  }
1238
- // "N waiting to approve" -> open the task's web detail (no inline approval)
1459
+ // "N waiting to approve" -> the in-pill detail, where the approval buttons live
1239
1460
  for (const el of tasksListEl.querySelectorAll("[data-approve]")) {
1240
1461
  el.addEventListener("click", (e) => {
1241
1462
  e.stopPropagation();
1242
- window.relay.openTask(el.getAttribute("data-approve"));
1463
+ showTaskDetail(el.getAttribute("data-approve"));
1243
1464
  });
1244
1465
  }
1466
+ // Row click opens the live in-pill detail; opening in Claude/Codex is an
1467
+ // explicit action inside it.
1245
1468
  for (const el of tasksListEl.querySelectorAll(".task")) {
1246
- el.addEventListener("click", () => window.relay.openTask(el.getAttribute("data-task")));
1469
+ el.addEventListener("click", () => showTaskDetail(el.getAttribute("data-task")));
1247
1470
  }
1248
1471
  }
1249
1472
  function taskRowHtml(t) {
@@ -1493,12 +1716,19 @@
1493
1716
 
1494
1717
  // ---------- views / tabs ----------
1495
1718
  function syncTabs() {
1496
- for (const tab of tabEls) tab.classList.toggle("active", tab.getAttribute("data-view") === activeView);
1719
+ // The in-pill task detail lives under the Tasks tab.
1720
+ const tabView = activeView === "taskDetail" ? "tasks" : activeView;
1721
+ for (const tab of tabEls) tab.classList.toggle("active", tab.getAttribute("data-view") === tabView);
1497
1722
  }
1498
1723
  function applyView() {
1499
1724
  relaysViewEl.classList.toggle("hidden", activeView !== "relays");
1500
1725
  tasksViewEl.classList.toggle("hidden", activeView !== "tasks");
1726
+ taskDetailViewEl.classList.toggle("hidden", activeView !== "taskDetail");
1501
1727
  contactsViewEl.classList.toggle("hidden", activeView !== "contacts");
1728
+ if (activeView !== "taskDetail" && taskDetailTimer) {
1729
+ clearInterval(taskDetailTimer);
1730
+ taskDetailTimer = null;
1731
+ }
1502
1732
  if (activeView === "contacts") {
1503
1733
  loadContacts();
1504
1734
  } else {
@@ -1550,6 +1780,9 @@
1550
1780
  else notifyArrival(row);
1551
1781
  });
1552
1782
  if (window.relay.onOpenFull) window.relay.onOpenFull(() => trayOpen());
1783
+ // The OS window was just re-shown and main re-asserted click-through; reset the
1784
+ // local flag so the next mousemove re-runs the interactivity handshake cleanly.
1785
+ if (window.relay.onShown) window.relay.onShown(() => { interactive = false; });
1553
1786
  if (window.relay.onOpenDone) window.relay.onOpenDone((id) => stopOpening(id));
1554
1787
  window.relay.refresh().then((p) => {
1555
1788
  onPayload(p);
package/overlay/main.cjs CHANGED
@@ -226,6 +226,8 @@ function readRelays() {
226
226
  provider: p.provider || null,
227
227
  contentPath: p.contentPath || null,
228
228
  expectedVersion: p.expectedVersion || null,
229
+ humanAnswered: Boolean(p.humanAnswered),
230
+ humanAnswerMarkdown: p.humanAnswerMarkdown || null,
229
231
  }))
230
232
  .sort((a, b) => {
231
233
  if (a.unread !== b.unread) return a.unread ? -1 : 1; // unread always on top
@@ -324,7 +326,7 @@ async function pushInbox(force) {
324
326
  if (dismissed) {
325
327
  if (hostRunning) {
326
328
  ghostActive = true;
327
- if (!win.isVisible()) win.showInactive();
329
+ showOverlayWindow();
328
330
  win.webContents.send("newRelay", fresh[0], { ghost: true });
329
331
  }
330
332
  } else {
@@ -454,7 +456,7 @@ async function declineShare(taskId, approvalId) {
454
456
  });
455
457
  }
456
458
 
457
- async function answerQuestion(taskId, messageId, text) {
459
+ async function answerQuestion(taskId, messageId, text, packetId) {
458
460
  const answerMarkdown = String(text || "").trim();
459
461
  if (!taskId || !messageId) return { ok: false, error: "Missing task or message id." };
460
462
  if (!answerMarkdown) return { ok: false, error: "Write an answer first." };
@@ -464,9 +466,27 @@ async function answerQuestion(taskId, messageId, text) {
464
466
  answerMarkdown,
465
467
  idempotencyKey: idempotencyKey("answer"),
466
468
  });
469
+ // The reply box must not survive a successful answer: record it on the staged
470
+ // packet immediately (the daemon reconciles the same way on its next poll).
471
+ if (packetId) markQuestionAnswered(packetId, answerMarkdown);
467
472
  });
468
473
  }
469
474
 
475
+ function markQuestionAnswered(packetId, answerMarkdown) {
476
+ const store = readStore();
477
+ const row = store.packets && store.packets[packetId];
478
+ if (!row) return;
479
+ store.packets[packetId] = {
480
+ ...row,
481
+ state: "read",
482
+ quickReply: false,
483
+ humanAnswered: true,
484
+ humanAnswerMarkdown: answerMarkdown,
485
+ updatedAt: new Date().toISOString(),
486
+ };
487
+ writeStateAtomic(store);
488
+ }
489
+
470
490
  function taskVersion(taskId) {
471
491
  const task = tasksCache.find((t) => t.id === taskId);
472
492
  return task && task.version != null ? task.version : null;
@@ -686,13 +706,25 @@ function anchorTopRight() {
686
706
  return { x: wa.x + wa.width - WIN.width - MARGIN, y: wa.y + MARGIN, width: WIN.width, height: WIN.height };
687
707
  }
688
708
 
709
+ // Show the overlay window. Electron's forwarded-mousemove stream (the thing that
710
+ // lets the renderer detect hover on a click-through window) can stall across a
711
+ // hide()/show() cycle, leaving the window permanently click-through. Re-assert
712
+ // setIgnoreMouseEvents on every hidden->shown transition and tell the renderer to
713
+ // reset its interactivity handshake.
714
+ function showOverlayWindow() {
715
+ if (!win || win.isDestroyed() || win.isVisible()) return;
716
+ win.showInactive();
717
+ win.setIgnoreMouseEvents(true, { forward: true });
718
+ win.webContents.send("shown");
719
+ }
720
+
689
721
  function maybeShow() {
690
722
  if (!win || win.isDestroyed()) return;
691
723
  // Dismissed keeps the window hidden except while a ghost notification is up.
692
724
  // trayForcedVisible honors an explicit menu-bar click even when no host runs.
693
725
  const wanted = (hostRunning || trayForcedVisible) && (!dismissed || ghostActive);
694
726
  if (wanted) {
695
- if (!win.isVisible()) win.showInactive();
727
+ showOverlayWindow();
696
728
  } else if (win.isVisible()) {
697
729
  win.hide();
698
730
  }
@@ -809,11 +841,23 @@ ipcMain.handle("relay:refreshTasks", async () => {
809
841
  await pushInbox(true);
810
842
  return tasksCache;
811
843
  });
844
+ // Live task detail for the in-pill task view (GET /v1/tasks/:id, same payload the
845
+ // web detail page renders from). The renderer polls this while the view is open.
846
+ ipcMain.handle("relay:taskStatus", async (_e, taskId) => {
847
+ if (!taskId) return { ok: false, error: "Missing task id." };
848
+ try {
849
+ const client = await relayClient();
850
+ const res = await client.getTask(taskId);
851
+ return { ok: true, task: (res && res.task) || res };
852
+ } catch (error) {
853
+ return { ok: false, error: error && error.message ? error.message : String(error) };
854
+ }
855
+ });
812
856
  ipcMain.handle("relay:accept", (_e, taskId, participantId) => acceptTask(taskId, participantId));
813
857
  ipcMain.handle("relay:reject", (_e, taskId, participantId) => rejectTask(taskId, participantId));
814
858
  ipcMain.handle("relay:approve", (_e, taskId, approvalId) => approveShare(taskId, approvalId));
815
859
  ipcMain.handle("relay:decline", (_e, taskId, approvalId) => declineShare(taskId, approvalId));
816
- ipcMain.handle("relay:answer", (_e, taskId, messageId, text) => answerQuestion(taskId, messageId, text));
860
+ ipcMain.handle("relay:answer", (_e, taskId, messageId, text, packetId) => answerQuestion(taskId, messageId, text, packetId));
817
861
  ipcMain.handle("relay:contacts", () => readContacts());
818
862
  ipcMain.handle("relay:contactSave", (_e, input) => saveContact(input));
819
863
 
@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld("relay", {
6
6
  onNewRelay: (cb) => ipcRenderer.on("newRelay", (_e, relay, opts) => cb(relay, opts || {})),
7
7
  onOpenDone: (cb) => ipcRenderer.on("openDone", (_e, id) => cb(id)),
8
8
  onOpenFull: (cb) => ipcRenderer.on("openFull", () => cb()), // menu-bar icon clicked
9
+ onShown: (cb) => ipcRenderer.on("shown", () => cb()), // window re-shown; reset hover handshake
9
10
 
10
11
  // pull the full payload on demand
11
12
  refresh: () => ipcRenderer.invoke("relay:get"),
@@ -20,11 +21,12 @@ contextBridge.exposeInMainWorld("relay", {
20
21
  reject: (taskId, participantId) => ipcRenderer.invoke("relay:reject", taskId, participantId),
21
22
  approve: (taskId, approvalId) => ipcRenderer.invoke("relay:approve", taskId, approvalId),
22
23
  decline: (taskId, approvalId) => ipcRenderer.invoke("relay:decline", taskId, approvalId),
23
- answer: (taskId, messageId, text) => ipcRenderer.invoke("relay:answer", taskId, messageId, text),
24
+ answer: (taskId, messageId, text, packetId) => ipcRenderer.invoke("relay:answer", taskId, messageId, text, packetId),
24
25
 
25
26
  // tasks view
26
27
  refreshTasks: () => ipcRenderer.invoke("relay:refreshTasks"),
27
28
  openTask: (taskId) => ipcRenderer.send("relay:openTask", taskId),
29
+ taskStatus: (taskId) => ipcRenderer.invoke("relay:taskStatus", taskId),
28
30
 
29
31
  // contacts
30
32
  contacts: () => ipcRenderer.invoke("relay:contacts"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -251,9 +251,14 @@ async function materializeRowInHost({ id, row, rowState = {}, host = "claude", l
251
251
  setThreadPinned(threadId, true);
252
252
  } catch {}
253
253
  await waitForCodexOpenReadiness(threadId);
254
- const desktopOpenResult = await refreshCodexDesktopForThreads([threadId], { force: true, openThreadId: threadId });
254
+ // Hydrate the recents rail over the bridge, but do NOT navigate through it:
255
+ // the bridge's hotkey-window route renders the desktop's in-memory copy of the
256
+ // thread, which is stale/empty for threads whose visible turn was appended to
257
+ // the rollout after thread/start. The codex:// deep link below goes through
258
+ // Codex's own protocol handler, which loads the thread fully from disk.
259
+ const desktopOpenResult = await refreshCodexDesktopForThreads([threadId], { force: true });
255
260
  openedInHost = Boolean(desktopOpenResult?.ok);
256
- skipExternalOpen = openedInHost;
261
+ skipExternalOpen = false;
257
262
  url = `codex://threads/${encodeURIComponent(threadId)}`;
258
263
  } else {
259
264
  let claudeNativeSession = rowState.claudeNativeSession || null;
package/src/mcp.js CHANGED
@@ -543,7 +543,21 @@ export async function runMcpServer() {
543
543
  try {
544
544
  return await handleCall(client, req.params.name, req.params.arguments || {});
545
545
  } catch (err) {
546
- return { content: [{ type: "text", text: `Relay error: ${err.message}` }], isError: true };
546
+ // Surface the API's validation detail so the calling agent can self-correct
547
+ // instead of guessing what "invalid_request" meant.
548
+ let detail = "";
549
+ const issues = err && err.body && Array.isArray(err.body.issues) ? err.body.issues : null;
550
+ if (issues && issues.length) {
551
+ detail =
552
+ "\n" +
553
+ issues
554
+ .slice(0, 8)
555
+ .map((i) => `- ${Array.isArray(i.path) && i.path.length ? i.path.join(".") : "(request)"}: ${i.message}`)
556
+ .join("\n");
557
+ } else if (err && err.body && typeof err.body.instruction === "string") {
558
+ detail = `\n${err.body.instruction}`;
559
+ }
560
+ return { content: [{ type: "text", text: `Relay error: ${err.message}${detail}` }], isError: true };
547
561
  }
548
562
  });
549
563
 
@@ -287,6 +287,11 @@ function senderForNotification(notification) {
287
287
  // anyway via RELAY_HIDDEN_KINDS, but the staged senderName must still be coherent.)
288
288
  if (notification.kind === "task_request") return "Your agent";
289
289
  if (notification.kind === "share_approval") return "Your agent";
290
+ // Task lifecycle notices (completed/cancelled/failed) are the agent run reporting
291
+ // back, not a person — label them as the automation, never "Someone".
292
+ if (notification.kind === "task_completed") return "Your agent";
293
+ if (notification.kind === "task_cancelled") return "Your agent";
294
+ if (notification.kind === "task_failed") return "Your agent";
290
295
  if (notification.kind === "connector_reauth") return "Relay connectors";
291
296
  return "Someone";
292
297
  }
@@ -453,6 +458,44 @@ export function defaultStageRelayCompanionItem(notification) {
453
458
  return stageRelayCompanionItem(notification);
454
459
  }
455
460
 
461
+ // A staged human_question keeps quickReply:true forever once the server stops
462
+ // listing it (requiresAnswer flips false after the human answers anywhere — the
463
+ // pill, the web app, or a host session). Reconcile staged questions against the
464
+ // live attention feed so the pill never renders an active reply box for a
465
+ // question that was already answered; carry the recorded answer when available.
466
+ export function reconcileAnsweredQuestions(relays = [], { statePath = companionStatePath() } = {}) {
467
+ const awaiting = new Set();
468
+ const answers = new Map();
469
+ for (const relay of relays) {
470
+ const message = relay?.message;
471
+ if (!message?.id) continue;
472
+ if (relay.requiresAnswer) awaiting.add(message.id);
473
+ const response = message.humanResponse;
474
+ if (response?.status === "answered" && response.answerMarkdown) {
475
+ answers.set(message.id, response.answerMarkdown);
476
+ }
477
+ }
478
+ const state = readCompanionState(statePath);
479
+ const packets = state.packets || {};
480
+ let changed = 0;
481
+ for (const [id, packet] of Object.entries(packets)) {
482
+ if (!packet || packet.relayNotificationKind !== "human_question") continue;
483
+ if (packet.humanAnswered) continue;
484
+ if (!packet.quickReply) continue;
485
+ if (packet.messageId && awaiting.has(packet.messageId)) continue;
486
+ packets[id] = {
487
+ ...packet,
488
+ quickReply: false,
489
+ humanAnswered: true,
490
+ humanAnswerMarkdown: packet.humanAnswerMarkdown || answers.get(packet.messageId) || null,
491
+ updatedAt: new Date().toISOString(),
492
+ };
493
+ changed += 1;
494
+ }
495
+ if (changed) writeCompanionState(statePath, state);
496
+ return changed;
497
+ }
498
+
456
499
  export function stagePlainRelayItem(
457
500
  { item, packet, attachmentUrls = {} },
458
501
  { statePath = companionStatePath(), forceUnread = false } = {},
@@ -13,6 +13,7 @@ import {
13
13
  defaultStageRelayCompanionItem,
14
14
  freshNotifications,
15
15
  markNotificationsProcessed,
16
+ reconcileAnsweredQuestions,
16
17
  stagePlainRelayItem as defaultStagePlainRelayItem,
17
18
  } from "./notifications.js";
18
19
 
@@ -149,6 +150,12 @@ async function pollHumanNotifications({ client, ledger, stageCompanionItem, stag
149
150
  }
150
151
  }
151
152
  if (staged.length) markNotificationsProcessed(ledger, staged);
153
+ try {
154
+ const reconciled = reconcileAnsweredQuestions(relays.relays || []);
155
+ if (reconciled) log(`marked ${reconciled} staged question(s) as answered`);
156
+ } catch (err) {
157
+ log(`answered-question reconciliation failed: ${err.message}`);
158
+ }
152
159
  return { notifications: staged, ordinaryRelays, events };
153
160
  } catch (err) {
154
161
  log(`Relay companion attention polling failed: ${err.message}`);