relay-companion 0.1.149 → 0.1.151

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/overlay/main.cjs CHANGED
@@ -3422,6 +3422,82 @@ function safeNoteStem(value) {
3422
3422
  return String(value || "note").replace(/[^0-9A-Za-z._-]+/g, "-").replace(/^-|-$/g, "");
3423
3423
  }
3424
3424
 
3425
+ // --- The task session feed ---------------------------------------------------
3426
+ // The session face reads the SAME native transcript the host app renders —
3427
+ // the forged Claude session JSONL or the Codex rollout — through the audited
3428
+ // reader in src/ai-session-transcript.js (redacted, thinking excluded,
3429
+ // newest-first). The preview polls this; there is no watcher anywhere in the
3430
+ // companion by design (fs.watch is unreliable across atomic renames).
3431
+ async function previewTaskSession(relayId) {
3432
+ const id = String(relayId || "");
3433
+ const row = rowById(id);
3434
+ if (!row || row.relayNotificationKind !== "task") return { ok: false, error: "Not a task." };
3435
+ const claudePath = row.claudeNativeSession && row.claudeNativeSession.sessionPath;
3436
+ const codexPath = row.codexSessionPath || row.sessionPath;
3437
+ const provider = claudePath ? "claude" : codexPath ? "codex" : "";
3438
+ if (!provider) return { ok: false, error: "The session has not started on this computer yet." };
3439
+ try {
3440
+ const { inspectAiSession } = await import("../src/ai-session-transcript.js");
3441
+ const target = provider === "claude"
3442
+ ? { id, provider, state: "", nativeRef: { transcriptPath: claudePath } }
3443
+ : { id, provider, state: "", nativeRef: { sessionPath: codexPath } };
3444
+ const page = await inspectAiSession(target, { limit: 200 });
3445
+ return {
3446
+ ok: true,
3447
+ provider,
3448
+ liveState: await previewTaskLiveState(row, provider),
3449
+ records: Array.isArray(page.records) ? page.records : [],
3450
+ // Receipts ride the poll: the content payload is pushed once, and the
3451
+ // Done flip should not wait for a window reopen.
3452
+ taskStartedAt: row.taskStartedAt || null,
3453
+ taskCompletedAt: row.taskCompletedAt || null,
3454
+ };
3455
+ } catch (error) {
3456
+ return { ok: false, error: (error && error.message) || String(error) };
3457
+ }
3458
+ }
3459
+
3460
+ // "Is the agent working right now?" — read from the same session registry the
3461
+ // pill's directory uses: a live inbox socket means the session process exists.
3462
+ async function previewTaskLiveState(row, provider) {
3463
+ try {
3464
+ if (provider !== "claude") return "unknown";
3465
+ const sessionId = row.claudeNativeSession && row.claudeNativeSession.sessionId;
3466
+ if (!sessionId) return "unknown";
3467
+ const { liveClaudeRegistrations, claudeState } = await import("../src/session-directory.js");
3468
+ const match = liveClaudeRegistrations().get(String(sessionId));
3469
+ return match ? claudeState(match) : "offline";
3470
+ } catch {
3471
+ return "unknown";
3472
+ }
3473
+ }
3474
+
3475
+ // Steer: put the reader's words INTO the live agent turn (Claude's inbox
3476
+ // socket), never to the sender — replies to the sender belong to the chat
3477
+ // face. Codex steering needs the app-server turn id; not wired yet.
3478
+ async function previewTaskSteer(input) {
3479
+ const id = String((input && input.relayId) || "");
3480
+ const body = String((input && input.body) || "").trim();
3481
+ if (!body) return { ok: false, error: "Write something first." };
3482
+ if (body.length > 20000) return { ok: false, error: "That is too long to send." };
3483
+ const row = rowById(id);
3484
+ if (!row || row.relayNotificationKind !== "task") return { ok: false, error: "Not a task." };
3485
+ const sessionId = row.claudeNativeSession && row.claudeNativeSession.sessionId;
3486
+ if (!sessionId) return { ok: false, error: "Steering needs the Claude session — open it in Claude instead." };
3487
+ try {
3488
+ const { liveClaudeRegistrations } = await import("../src/session-directory.js");
3489
+ const { sendClaudeSocket } = await import("../src/session-controller.js");
3490
+ const match = liveClaudeRegistrations().get(String(sessionId));
3491
+ if (!match || !match.socketLive || !match.messagingSocketPath) {
3492
+ return { ok: false, error: "The session isn't live right now — open it in Claude to continue." };
3493
+ }
3494
+ await sendClaudeSocket(match.messagingSocketPath, body);
3495
+ return { ok: true };
3496
+ } catch (error) {
3497
+ return { ok: false, error: (error && error.message) || String(error) };
3498
+ }
3499
+ }
3500
+
3425
3501
  function installActiveSpaceWatcher() {
3426
3502
  if (process.platform !== "darwin") return;
3427
3503
  subscribeActiveSpaceChanges(
@@ -3837,6 +3913,16 @@ ipcMain.handle("relay:preview:startTask", async (event, input) => {
3837
3913
  if (!isPreviewEvent(event)) return { ok: false, error: "Not the preview window." };
3838
3914
  return startTaskFromPreview(input);
3839
3915
  });
3916
+ // The session face's feed and its Steer verb. Preview-only, like everything
3917
+ // on this channel; the feed reads only this staged relay's own session.
3918
+ ipcMain.handle("relay:preview:session", (event, relayId) => {
3919
+ if (!isPreviewEvent(event)) return { ok: false, error: "Not the preview window." };
3920
+ return previewTaskSession(relayId);
3921
+ });
3922
+ ipcMain.handle("relay:preview:steer", (event, input) => {
3923
+ if (!isPreviewEvent(event)) return { ok: false, error: "Not the preview window." };
3924
+ return previewTaskSteer(input);
3925
+ });
3840
3926
  ipcMain.on("relay:openTask", (_e, taskId) => openTaskDetail(taskId));
3841
3927
  ipcMain.on("relay:openUrl", (_e, url) => openUrlTarget(url));
3842
3928
  ipcMain.on("relay:ack", (_e, id) => ackPacket(id));
@@ -150,5 +150,12 @@ contextBridge.exposeInMainWorld("relayPreview", {
150
150
  model: String(model || ""),
151
151
  effort: String(effort || ""),
152
152
  }),
153
+ // The session face: the live transcript feed and the Steer verb.
154
+ loadSession: (relayId) => ipcRenderer.invoke("relay:preview:session", String(relayId || "")),
155
+ steer: (relayId, body) =>
156
+ ipcRenderer.invoke("relay:preview:steer", {
157
+ relayId: String(relayId || ""),
158
+ body: String(body || ""),
159
+ }),
153
160
  renderMarkdown,
154
161
  });
@@ -6513,6 +6513,12 @@ contextBridge.exposeInMainWorld("relayPreview", {
6513
6513
  model: String(model || ""),
6514
6514
  effort: String(effort || "")
6515
6515
  }),
6516
+ // The session face: the live transcript feed and the Steer verb.
6517
+ loadSession: (relayId) => ipcRenderer.invoke("relay:preview:session", String(relayId || "")),
6518
+ steer: (relayId, body) => ipcRenderer.invoke("relay:preview:steer", {
6519
+ relayId: String(relayId || ""),
6520
+ body: String(body || "")
6521
+ }),
6516
6522
  renderMarkdown
6517
6523
  });
6518
6524
  /*! Bundled license information:
@@ -40,6 +40,9 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
40
40
  const runtimeWordsEl = document.getElementById("runtimeWords");
41
41
  const runtimePopEl = document.getElementById("runtimePop");
42
42
  const rtCodexItemEl = document.getElementById("rtCodexItem");
43
+ const sessionScrollEl = document.getElementById("sessionScroll");
44
+ const sessionListEl = document.getElementById("sessionList");
45
+ const sessionStateEl = document.getElementById("sessionState");
43
46
 
44
47
  const COMPOSER_MIN_HEIGHT = 42;
45
48
  const COMPOSER_MAX_HEIGHT = 124;
@@ -63,6 +66,14 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
63
66
  /** The task face's ignition state: composer starts the task, not a reply. */
64
67
  let taskMode = false;
65
68
  let starting = false;
69
+ /** A started task shows its live session in the message-face slot. */
70
+ let sessionMode = false;
71
+ let sessionTimer = 0;
72
+ let sessionFingerprint = "";
73
+ let sessionLive = { provider: "", liveState: "", error: "" };
74
+ /** Disclosure rows the reader has opened; keyed by a stable per-record key. */
75
+ const actOpen = new Set();
76
+ const SESSION_POLL_MS = 1200;
66
77
  /** The runtime the Start button will use. Claude Code · Opus 5 · high. */
67
78
  const runtime = { host: "claude", model: "claude-opus-5", effort: "high" };
68
79
  const MODEL_LABELS = { "claude-opus-5": "Opus 5", "claude-sonnet-5": "Sonnet 5" };
@@ -193,6 +204,19 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
193
204
  }
194
205
  taskRuntimeNoteEl.classList.add("gone");
195
206
  replyNoteEl.classList.remove("gone");
207
+ if (sessionMode && face !== "chat") {
208
+ // On the session face the composer talks to the AGENT: words land in the
209
+ // live turn without interrupting it. Replies to the sender live one
210
+ // slide away, on the conversation face.
211
+ const done = Boolean(text(content.taskCompletedAt));
212
+ replyInputEl.placeholder = done ? "Write a reply…" : "Steer the current turn…";
213
+ replyInputEl.disabled = sending;
214
+ const ready = !sending && Boolean(replyInputEl.value.trim());
215
+ replyButtonEl.disabled = !ready;
216
+ replyButtonEl.setAttribute("aria-disabled", ready ? "false" : "true");
217
+ replyButtonEl.textContent = sending ? "Sending…" : done ? "Send" : "Steer";
218
+ return;
219
+ }
196
220
  const hasTarget = Boolean(replyTargetId());
197
221
  replyInputEl.placeholder = composerPlaceholder();
198
222
  replyInputEl.disabled = sending || !hasTarget;
@@ -265,13 +289,13 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
265
289
  replyInputEl.value = "";
266
290
  autosizeComposer();
267
291
  renderTaskMeta();
292
+ // The window turns into the live session: the note appears as the one
293
+ // bubble, the agent's work streams in beneath it. The conversation (with
294
+ // the sender's "Started" copy) stays one slide away on the chip.
295
+ setSessionMode(true);
268
296
  refreshComposer();
269
297
  setNote("Started — your agent has it from here.");
270
- loadChat({ keepScroll: false })
271
- .then(() => {
272
- if (chat) showFace("chat");
273
- })
274
- .catch(() => {});
298
+ loadChat({ keepScroll: false }).catch(() => {});
275
299
  }
276
300
 
277
301
  function setNote(message, isError) {
@@ -754,6 +778,12 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
754
778
  await submitStart();
755
779
  return;
756
780
  }
781
+ // A running session's composer steers the agent; once the task is done it
782
+ // settles into an ordinary reply to the sender.
783
+ if (sessionMode && face !== "chat" && !text(content.taskCompletedAt)) {
784
+ await submitSteer();
785
+ return;
786
+ }
757
787
  if (sending) return;
758
788
  const body = replyInputEl.value.trim();
759
789
  const targetId = replyTargetId();
@@ -770,6 +800,347 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
770
800
  if (!replyInputEl.disabled) replyInputEl.focus();
771
801
  }
772
802
 
803
+ // --- The session face -------------------------------------------------------
804
+ // A started task's transcript, rendered as a calm document: the human's
805
+ // words in the one bubble, the agent's prose flat, and every action folded
806
+ // to a dimmed sentence. Evidence appears only when asked for.
807
+
808
+ const TOOL_ICONS = {
809
+ command:
810
+ '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><path d="M2.5 4.5 6 8l-3.5 3.5M8.5 11.5h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
811
+ file:
812
+ '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><path d="M9.5 1.5h-5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V4.5l-3-3Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="M9.5 1.5v3h3" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>',
813
+ search:
814
+ '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.4"/><path d="m10.5 10.5 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>',
815
+ web:
816
+ '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3"/><path d="M2.2 8h11.6M8 2c1.8 1.7 2.6 3.7 2.6 6S9.8 12.3 8 14C6.2 12.3 5.4 10.3 5.4 8S6.2 3.7 8 2Z" stroke="currentColor" stroke-width="1.1"/></svg>',
817
+ agent:
818
+ '<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><circle cx="4.5" cy="4" r="2" stroke="currentColor" stroke-width="1.3"/><circle cx="11.5" cy="12" r="2" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 6v2.5a2 2 0 0 0 2 2h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>',
819
+ };
820
+ const ACT_CHEVRON =
821
+ '<svg class="act-chev" viewBox="0 0 10 10" fill="none" aria-hidden="true"><path d="M3 1.5 7 5 3 8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
822
+
823
+ function parseToolInput(input) {
824
+ if (input && typeof input === "object") return input;
825
+ try {
826
+ return JSON.parse(String(input || "{}")) || {};
827
+ } catch {
828
+ return {};
829
+ }
830
+ }
831
+
832
+ function basename(value) {
833
+ const parts = String(value || "").split(/[\\/]/);
834
+ return parts[parts.length - 1] || String(value || "");
835
+ }
836
+
837
+ function firstWords(value, max) {
838
+ const clean = String(value || "").replace(/\s+/g, " ").trim();
839
+ return clean.length > max ? `${clean.slice(0, max - 1).trimEnd()}…` : clean;
840
+ }
841
+
842
+ /**
843
+ * The verb map: every tool gets an active and a completed sentence in plain
844
+ * words, with the object in mono. Nothing here shows code — the sentence is
845
+ * the surface, the evidence waits behind the click.
846
+ */
847
+ function describeTool(tool, input, running) {
848
+ const name = String(tool || "tool");
849
+ const args = parseToolInput(input);
850
+ const mono = (value) => (value ? { obj: firstWords(value, 64) } : {});
851
+ if (name === "Bash") {
852
+ return { icon: "command", text: running ? "Running a command" : "Ran a command", ...mono(args.command), evidence: "command" };
853
+ }
854
+ if (["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(name)) {
855
+ const file = basename(args.file_path || args.path || args.notebook_path);
856
+ return { icon: "file", text: `${running ? "Editing" : "Edited"}${file ? " " : ""}`, obj: file, evidence: "file" };
857
+ }
858
+ if (name === "Read") {
859
+ return { icon: "search", text: running ? "Reading" : "Read", obj: basename(args.file_path || args.path), evidence: "quiet" };
860
+ }
861
+ if (["Glob", "Grep"].includes(name)) {
862
+ return { icon: "search", text: running ? "Searching the code" : "Searched the code", ...mono(args.pattern || args.query), evidence: "quiet" };
863
+ }
864
+ if (["WebSearch", "WebFetch"].includes(name)) {
865
+ return { icon: "web", text: running ? "Searching the web" : "Searched the web", ...mono(args.query || args.url), evidence: "quiet" };
866
+ }
867
+ if (["Task", "Agent"].includes(name)) {
868
+ return { icon: "agent", text: running ? "Running an agent" : "Ran an agent", ...mono(args.description || args.prompt), evidence: "quiet" };
869
+ }
870
+ const mcp = name.match(/^mcp__([^_]+(?:[-_][^_]+)*?)__(.+)$/);
871
+ if (mcp) {
872
+ const server = mcp[1].replace(/[-_]+/g, " ").trim();
873
+ const toolWords = mcp[2].replace(/[-_]+/g, " ").trim();
874
+ return { icon: "web", text: `${running ? "Using" : "Used"} ${server}`, obj: toolWords, evidence: "quiet" };
875
+ }
876
+ return { icon: "command", text: `${running ? "Using" : "Used"} ${name}`, evidence: "quiet" };
877
+ }
878
+
879
+ /** Oldest-first entries the session face renders, built from reader records. */
880
+ function sessionEntries(records) {
881
+ const oldestFirst = records.slice().reverse();
882
+ const entries = [];
883
+ let seedDropped = false;
884
+ for (let i = 0; i < oldestFirst.length; i += 1) {
885
+ const record = oldestFirst[i] || {};
886
+ if (record.type === "message" && record.role === "assistant" && !seedDropped && entries.length === 0) {
887
+ // The forged seed: the brief the reader has already seen on the task
888
+ // face. Rendering it again would open every session with a wall.
889
+ seedDropped = true;
890
+ continue;
891
+ }
892
+ if (record.type === "message" && record.text) {
893
+ entries.push({ kind: record.role === "user" ? "user" : "prose", text: record.text, at: record.at, key: `m${i}` });
894
+ continue;
895
+ }
896
+ if (record.type === "progress" && record.text) {
897
+ entries.push({ kind: "progress", text: record.text, at: record.at, key: `p${i}` });
898
+ continue;
899
+ }
900
+ if (record.type === "tool_call") {
901
+ // Pair with the next result so the sentence knows its tense and the
902
+ // click has its evidence.
903
+ let result = null;
904
+ for (let j = i + 1; j < oldestFirst.length; j += 1) {
905
+ const candidate = oldestFirst[j] || {};
906
+ if (candidate.type === "tool_call") break;
907
+ if (candidate.type === "tool_result") {
908
+ result = candidate;
909
+ break;
910
+ }
911
+ }
912
+ entries.push({ kind: "act", tool: record.tool, input: record.input, result, at: record.at, key: `t${i}` });
913
+ }
914
+ }
915
+ return entries;
916
+ }
917
+
918
+ function buildActRow(entry, { live }) {
919
+ const running = !entry.result && live;
920
+ const spec = describeTool(entry.tool, entry.input, running);
921
+ const act = document.createElement("div");
922
+ act.className = "act";
923
+ if (actOpen.has(entry.key)) act.classList.add("open");
924
+ const head = document.createElement("button");
925
+ head.className = "act-head";
926
+ head.type = "button";
927
+ head.setAttribute("aria-expanded", actOpen.has(entry.key) ? "true" : "false");
928
+ const icon = document.createElement("span");
929
+ icon.className = "act-ic";
930
+ icon.innerHTML = TOOL_ICONS[spec.icon] || TOOL_ICONS.command;
931
+ const sum = document.createElement("span");
932
+ sum.className = `act-sum${running ? " shimmer" : ""}`;
933
+ sum.append(document.createTextNode(spec.text + (spec.obj ? " " : "")));
934
+ if (spec.obj) {
935
+ const obj = document.createElement("span");
936
+ obj.className = "obj";
937
+ obj.textContent = spec.obj;
938
+ sum.appendChild(obj);
939
+ }
940
+ if (running) sum.append(document.createTextNode("…"));
941
+ head.append(icon, sum);
942
+ head.insertAdjacentHTML("beforeend", ACT_CHEVRON);
943
+ act.appendChild(head);
944
+
945
+ const body = document.createElement("div");
946
+ body.className = "act-body";
947
+ const ev = document.createElement("div");
948
+ ev.className = "ev";
949
+ const args = parseToolInput(entry.input);
950
+ if (spec.evidence === "command" && args.command) {
951
+ const cmd = document.createElement("div");
952
+ cmd.className = "ev-cmd";
953
+ cmd.textContent = String(args.command);
954
+ ev.appendChild(cmd);
955
+ } else {
956
+ const cmd = document.createElement("pre");
957
+ cmd.className = "ev-out";
958
+ cmd.textContent = firstWords(JSON.stringify(args, null, 1), 2000) || "(no input)";
959
+ ev.appendChild(cmd);
960
+ }
961
+ if (entry.result && entry.result.output) {
962
+ const out = document.createElement("pre");
963
+ out.className = "ev-out";
964
+ out.textContent = String(entry.result.output);
965
+ ev.appendChild(out);
966
+ }
967
+ if (entry.result) {
968
+ const foot = document.createElement("div");
969
+ foot.className = `ev-foot${entry.result.isError ? " error" : ""}`;
970
+ foot.textContent = entry.result.isError ? "Failed" : "Completed";
971
+ ev.appendChild(foot);
972
+ }
973
+ body.appendChild(ev);
974
+ act.appendChild(body);
975
+
976
+ head.addEventListener("click", () => {
977
+ if (actOpen.has(entry.key)) actOpen.delete(entry.key);
978
+ else actOpen.add(entry.key);
979
+ act.classList.toggle("open");
980
+ head.setAttribute("aria-expanded", act.classList.contains("open") ? "true" : "false");
981
+ });
982
+ return act;
983
+ }
984
+
985
+ function workedForLabel() {
986
+ const startAt = parseDate(content.taskStartedAt);
987
+ if (!startAt) return "";
988
+ const endAt = parseDate(content.taskCompletedAt) || new Date();
989
+ const minutes = Math.max(1, Math.round((endAt - startAt) / 60000));
990
+ const clock = minutes >= 60 ? `${Math.floor(minutes / 60)} h ${minutes % 60} min` : `${minutes} min`;
991
+ return content.taskCompletedAt ? `Done · worked for ${clock}` : `Working for ${clock}`;
992
+ }
993
+
994
+ function renderSession(records) {
995
+ const done = Boolean(text(content.taskCompletedAt));
996
+ const live = !done && sessionLive.liveState === "active";
997
+ const entries = sessionEntries(records);
998
+ const nearBottom = sessionScrollEl.scrollHeight - sessionScrollEl.scrollTop - sessionScrollEl.clientHeight < 90;
999
+
1000
+ const fragment = document.createDocumentFragment();
1001
+ const marker = document.createElement("div");
1002
+ marker.className = "turn-marker";
1003
+ marker.textContent = done
1004
+ ? "Turn complete"
1005
+ : live
1006
+ ? "Current turn · in progress"
1007
+ : sessionLive.liveState === "needs_input"
1008
+ ? "Current turn · needs you in Claude"
1009
+ : "Session";
1010
+ fragment.appendChild(marker);
1011
+
1012
+ let group = null;
1013
+ for (const entry of entries) {
1014
+ if (entry.kind === "act" || entry.kind === "progress") {
1015
+ if (!group) {
1016
+ group = document.createElement("div");
1017
+ group.className = "group-gap";
1018
+ fragment.appendChild(group);
1019
+ }
1020
+ } else {
1021
+ group = null;
1022
+ }
1023
+ if (entry.kind === "user") {
1024
+ const bubble = document.createElement("div");
1025
+ bubble.className = "user-msg";
1026
+ bubble.textContent = entry.text;
1027
+ fragment.appendChild(bubble);
1028
+ } else if (entry.kind === "prose") {
1029
+ const prose = document.createElement("div");
1030
+ prose.className = "agent-prose";
1031
+ try {
1032
+ const html = bridge.renderMarkdown(entry.text);
1033
+ const article = document.createElement("div");
1034
+ article.className = "markdown";
1035
+ article.innerHTML = html;
1036
+ prose.appendChild(article);
1037
+ } catch {
1038
+ prose.textContent = entry.text;
1039
+ }
1040
+ fragment.appendChild(prose);
1041
+ } else if (entry.kind === "progress") {
1042
+ const line = document.createElement("div");
1043
+ line.className = "act";
1044
+ const sum = document.createElement("span");
1045
+ sum.className = "act-sum";
1046
+ sum.style.fontSize = "11px";
1047
+ sum.textContent = `› ${entry.text}`;
1048
+ line.appendChild(sum);
1049
+ group.appendChild(line);
1050
+ } else if (entry.kind === "act") {
1051
+ group.appendChild(buildActRow(entry, { live }));
1052
+ }
1053
+ }
1054
+ const worked = workedForLabel();
1055
+ if (worked) {
1056
+ const foot = document.createElement("div");
1057
+ foot.className = "worked-for";
1058
+ foot.textContent = worked;
1059
+ fragment.appendChild(foot);
1060
+ }
1061
+ sessionListEl.replaceChildren(fragment);
1062
+ sessionStateEl.classList.add("gone");
1063
+ if (nearBottom) {
1064
+ requestAnimationFrame(() => {
1065
+ sessionScrollEl.scrollTop = sessionScrollEl.scrollHeight;
1066
+ });
1067
+ }
1068
+ }
1069
+
1070
+ async function pollSession() {
1071
+ if (!sessionMode) return;
1072
+ let result;
1073
+ try {
1074
+ result = await bridge.loadSession(text(content.relayId));
1075
+ } catch (error) {
1076
+ result = { ok: false, error: (error && error.message) || String(error) };
1077
+ }
1078
+ if (!sessionMode) return;
1079
+ if (!result || !result.ok) {
1080
+ sessionLive = { provider: "", liveState: "", error: text(result && result.error) };
1081
+ if (!sessionListEl.childElementCount) {
1082
+ sessionStateEl.textContent = sessionLive.error || "The session is not readable yet.";
1083
+ sessionStateEl.classList.remove("gone");
1084
+ }
1085
+ return;
1086
+ }
1087
+ sessionLive = { provider: result.provider, liveState: text(result.liveState), error: "" };
1088
+ // Receipts ride the poll — the Done flip lands here, not on a reopen.
1089
+ if (text(result.taskStartedAt)) content.taskStartedAt = text(result.taskStartedAt);
1090
+ if (text(result.taskCompletedAt) && !text(content.taskCompletedAt)) {
1091
+ content.taskCompletedAt = text(result.taskCompletedAt);
1092
+ renderTaskMeta();
1093
+ }
1094
+ const fingerprint = `${result.provider}|${sessionLive.liveState}|${text(content.taskCompletedAt)}|${JSON.stringify(result.records)}`;
1095
+ if (fingerprint === sessionFingerprint && sessionListEl.childElementCount) return;
1096
+ sessionFingerprint = fingerprint;
1097
+ renderSession(result.records || []);
1098
+ refreshComposer();
1099
+ }
1100
+
1101
+ function setSessionMode(next) {
1102
+ sessionMode = Boolean(next);
1103
+ detailScrollEl.classList.toggle("gone", sessionMode);
1104
+ sessionScrollEl.classList.toggle("gone", !sessionMode);
1105
+ if (sessionTimer) {
1106
+ clearInterval(sessionTimer);
1107
+ sessionTimer = 0;
1108
+ }
1109
+ if (sessionMode) {
1110
+ sessionFingerprint = "";
1111
+ actOpen.clear();
1112
+ sessionStateEl.textContent = "Reading the session…";
1113
+ sessionStateEl.classList.remove("gone");
1114
+ pollSession().catch(() => {});
1115
+ sessionTimer = setInterval(() => pollSession().catch(() => {}), SESSION_POLL_MS);
1116
+ }
1117
+ }
1118
+
1119
+ async function submitSteer() {
1120
+ if (sending) return;
1121
+ const body = replyInputEl.value.trim();
1122
+ const id = text(content.relayId);
1123
+ if (!body || !id) return;
1124
+ sending = true;
1125
+ refreshComposer();
1126
+ let result;
1127
+ try {
1128
+ result = await bridge.steer(id, body);
1129
+ } catch (error) {
1130
+ result = { ok: false, error: (error && error.message) || String(error) };
1131
+ }
1132
+ sending = false;
1133
+ if (result && result.ok) {
1134
+ replyInputEl.value = "";
1135
+ autosizeComposer();
1136
+ setNote("Steered — it lands in the current turn without interrupting it.");
1137
+ pollSession().catch(() => {});
1138
+ } else {
1139
+ setNote(text(result && result.error) || "The steer did not send.", true);
1140
+ }
1141
+ refreshComposer();
1142
+ }
1143
+
773
1144
  // --- The message face -------------------------------------------------------
774
1145
 
775
1146
  async function renderContent(next) {
@@ -826,6 +1197,7 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
826
1197
  renderRuntimeWords();
827
1198
  }
828
1199
  renderTaskMeta();
1200
+ setSessionMode(text(row.openFace) === "task" && Boolean(text(row.taskStartedAt)));
829
1201
  showFace(opensInChat ? "chat" : "message", { force: true });
830
1202
  setNote("");
831
1203
  refreshComposer();
@@ -262,6 +262,76 @@
262
262
  .rt-item .rt-sub { margin-left:auto; color:var(--muted-2); font-size:11px; }
263
263
  .rt-sep { height:1px; margin:2px 8px; background:var(--line); }
264
264
 
265
+ /* ---- The session face -------------------------------------------------
266
+ A started task's transcript is a CALM DOCUMENT: the human speaks in the
267
+ one bubble, the agent writes in flat prose, and everything the agent DID
268
+ collapses to a dimmed one-line sentence you can expand. Four text
269
+ strengths off one ink; no spinners — live state is a cadenced shimmer on
270
+ the words themselves. */
271
+ .session-scroll {
272
+ flex:1 1 auto; min-height:0; overflow:auto; overscroll-behavior:contain;
273
+ padding:20px 26px 26px; scrollbar-gutter:stable;
274
+ }
275
+ .session-scroll::-webkit-scrollbar { width:11px; height:11px; }
276
+ .session-scroll::-webkit-scrollbar-thumb {
277
+ border:3px solid var(--paper); border-radius:999px;
278
+ background:rgba(31,26,23,.17); background-clip:padding-box;
279
+ }
280
+ .session-inner { width:min(100%, 720px); margin:0 auto; display:flex; flex-direction:column; gap:15px; }
281
+ .session-inner .group-gap { display:flex; flex-direction:column; gap:5px; }
282
+ .turn-marker { display:flex; align-items:center; gap:10px; color:var(--muted-2); font-size:11px; white-space:nowrap; }
283
+ .turn-marker::after { content:""; flex:1 1 auto; height:1px; background:var(--line); }
284
+ .user-msg {
285
+ align-self:flex-end; width:fit-content; max-width:min(82%, 430px);
286
+ background:var(--canvas); color:var(--ink-soft);
287
+ border-radius:14px 14px 4px 14px; padding:10px 13px;
288
+ font-size:13px; line-height:1.55; user-select:text; overflow-wrap:anywhere;
289
+ }
290
+ .agent-prose { font-size:13.5px; line-height:1.62; color:var(--ink-soft); user-select:text; max-width:62ch; overflow-wrap:anywhere; }
291
+ .agent-prose .markdown { font-size:13.5px; line-height:1.62; }
292
+ .act { display:flex; flex-direction:column; align-items:flex-start; }
293
+ .act-head {
294
+ appearance:none; border:0; background:transparent; cursor:pointer; text-align:left;
295
+ display:inline-flex; align-items:center; gap:7px; max-width:100%;
296
+ padding:3px 6px; margin:-3px -6px; border-radius:6px; font:inherit;
297
+ transition:background-color .13s ease;
298
+ }
299
+ .act-head:hover { background:var(--hover); }
300
+ .act-ic { flex:0 0 auto; width:14px; height:14px; color:var(--muted-2); display:grid; place-items:center; }
301
+ .act-sum {
302
+ font-size:13px; line-height:1.5; color:var(--muted); min-width:0;
303
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
304
+ transition:color .13s ease;
305
+ }
306
+ .act-sum .obj { font-family:var(--mono); font-size:11.5px; }
307
+ .act-head:hover .act-sum { color:var(--ink); }
308
+ .act-chev { flex:0 0 auto; width:10px; height:10px; color:var(--muted-2); opacity:0; transition:opacity .13s ease, transform .2s ease; }
309
+ .act-head:hover .act-chev, .act.open .act-chev, .act-head:focus-visible .act-chev { opacity:1; }
310
+ .act.open .act-chev { transform:rotate(90deg); }
311
+ .act-body { display:none; margin:7px 0 3px 21px; max-width:min(100%, 560px); align-self:stretch; }
312
+ .act.open .act-body { display:block; }
313
+ .ev { border:1px solid var(--line); border-radius:9px; background:var(--canvas); overflow:hidden; }
314
+ .ev-cmd { padding:8px 11px; font-family:var(--mono); font-size:11px; line-height:1.6; color:var(--ink-soft); user-select:text; overflow-wrap:anywhere; }
315
+ .ev-cmd::before { content:"$ "; color:var(--muted-2); }
316
+ .ev-out {
317
+ border-top:1px solid var(--line); padding:8px 11px; margin:0;
318
+ font-family:var(--mono); font-size:10.5px; line-height:1.6; color:var(--muted);
319
+ white-space:pre-wrap; overflow-wrap:anywhere; user-select:text;
320
+ max-height:260px; overflow:auto;
321
+ }
322
+ .ev-foot { border-top:1px solid var(--line); padding:5px 11px; font-size:10.5px; font-weight:600; color:var(--ok); }
323
+ .ev-foot.error { color:var(--danger); }
324
+ /* Live state is a cadenced sweep across the words themselves — one pass a
325
+ second, three seconds of rest. Presence without anxiety. */
326
+ .shimmer {
327
+ background:linear-gradient(100deg, var(--muted) 40%, var(--ink) 50%, var(--muted) 60%);
328
+ background-size:240% 100%; background-position:120% 0;
329
+ -webkit-background-clip:text; background-clip:text; color:transparent;
330
+ animation:relayShimmer 4s linear infinite;
331
+ }
332
+ @keyframes relayShimmer { 0% { background-position:120% 0; } 25% { background-position:-60% 0; } 100% { background-position:-60% 0; } }
333
+ .worked-for { font-size:11px; color:var(--muted-2); }
334
+
265
335
  /* ---- Chat header ---------------------------------------------------- */
266
336
  .chat-head { display:flex; align-items:center; gap:10px; min-width:0; }
267
337
  .chat-back {
@@ -536,6 +606,12 @@
536
606
  <p class="render-error gone" id="renderError" role="alert"></p>
537
607
  </div>
538
608
  </main>
609
+ <!-- A started task swaps the reading surface for its live session:
610
+ same face slot, so the chat face stays one slide away. -->
611
+ <main class="session-scroll gone" id="sessionScroll" tabindex="0" aria-label="Task session">
612
+ <div class="session-inner" id="sessionList"></div>
613
+ <p class="chat-state gone" id="sessionState"></p>
614
+ </main>
539
615
  </section>
540
616
 
541
617
  <section class="face" id="faceChat" aria-label="Conversation" aria-hidden="true">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.149",
3
+ "version": "0.1.151",
4
4
  "description": "Companion CLI for Relay (sendrelays.com): pairs this machine with your Relay account so coding agents like Claude Code and Codex can send and receive Relay messages.",
5
5
  "homepage": "https://sendrelays.com",
6
6
  "repository": {
@@ -21,7 +21,7 @@ import { relayRowTitle, renderRelayRowSeed } from "./relay-briefing.js";
21
21
  import { claudeDesktopSessionsDir, claudeProjectsDir } from "./host-paths.js";
22
22
  import { relayClaudePermissionMode } from "./claude-session-runtime.js";
23
23
 
24
- const DEFAULT_CLAUDE_METADATA_MODEL = "claude-opus-4-8";
24
+ const DEFAULT_CLAUDE_METADATA_MODEL = "claude-opus-5";
25
25
  const CLAUDE_DESKTOP_LIVE_TITLE_LIMITATION = {
26
26
  attempted: true,
27
27
  supported: "best-effort",
package/src/mcp.js CHANGED
@@ -82,7 +82,12 @@ export const TOOLS = [
82
82
  },
83
83
  },
84
84
  },
85
- kind: { type: "string", enum: ["message", "handoff"], description: "Use message for notes/questions; handoff for larger context takeovers." },
85
+ kind: {
86
+ type: "string",
87
+ enum: ["message", "handoff", "task"],
88
+ description:
89
+ "Use message for notes/questions; handoff for larger context takeovers; task to ask the recipient to run a JOB on their machine. A task arrives with a Start button: when the recipient presses it, their own coding agent runs the job and their completion reply marks it Done for the sender. For a task, the title names the job ('Chase down the Windows tasklist poll-storm'), the body is the brief — context, concrete steps if known, and a 'Done means:' line stating what finished looks like. One recipient only; groups refuse tasks.",
90
+ },
86
91
  title: {
87
92
  type: "string",
88
93
  description:
package/src/runtime.js CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { relayClaudePermissionMode } from "./claude-session-runtime.js";
5
+ import { cliBinaryPath, installedCliVersions } from "./desktop-wake.js";
5
6
  import readline from "node:readline";
6
7
  import { randomUUID } from "node:crypto";
7
8
  import { execFileSync, spawn, spawnSync } from "node:child_process";
@@ -528,8 +529,25 @@ function clearQueuedInput(sessionRef) {
528
529
  return next;
529
530
  }
530
531
 
532
+ /**
533
+ * Claude Desktop downloads the REAL Claude Code CLI (to ~/Library/Application
534
+ * Support/Claude/claude-code/<ver>/…) and spawns it for every session, so a
535
+ * desktop-only machine has a full runtime without `claude` ever being on PATH.
536
+ * `which claude` alone therefore under-reports; count the newest downloaded
537
+ * binary as installed.
538
+ */
539
+ function desktopClaudeCliExists() {
540
+ try {
541
+ const versions = installedCliVersions();
542
+ for (let i = versions.length - 1; i >= 0; i -= 1) {
543
+ if (fs.existsSync(cliBinaryPath(versions[i]))) return true;
544
+ }
545
+ } catch {}
546
+ return false;
547
+ }
548
+
531
549
  function hostStatus(kind, command, exists, version) {
532
- const installed = exists(command);
550
+ const installed = exists(command) || (kind === "claude_code" && desktopClaudeCliExists());
533
551
  const adapter = kind === "codex" && process.env.RELAY_CODEX_ADAPTER !== "cli"
534
552
  ? "app_server"
535
553
  : kind === "claude_code" && process.env.RELAY_CLAUDE_ADAPTER !== "cli"
@@ -67,7 +67,9 @@ function liveClaudeRegistration(sessionId) {
67
67
  return rows.sort((a, b) => b.updatedAt - a.updatedAt)[0] || null;
68
68
  }
69
69
 
70
- function sendClaudeSocket(socketPath, prompt, timeoutMs = 30_000) {
70
+ // Exported for the task preview's Steer verb: one line of user text into a
71
+ // live session's inbox socket.
72
+ export function sendClaudeSocket(socketPath, prompt, timeoutMs = 30_000) {
71
73
  return new Promise((resolve, reject) => {
72
74
  const socket = net.createConnection(socketPath);
73
75
  const timer = setTimeout(() => {
@@ -114,7 +114,9 @@ export function discoverCodexSessions({ homeDir = codexHome(), nowMs = Date.now(
114
114
  return rows.sort((a, b) => Date.parse(b.lastActiveAt) - Date.parse(a.lastActiveAt)).slice(0, MAX_SESSIONS_PER_PROVIDER);
115
115
  }
116
116
 
117
- function liveClaudeRegistrations(configDir) {
117
+ // Exported for the task preview's session face: it needs "is this forged
118
+ // session live, and through which socket" without the full directory sweep.
119
+ export function liveClaudeRegistrations(configDir = process.env.CLAUDE_CONFIG_DIR || claudeHome()) {
118
120
  const dir = process.env.RELAY_CLAUDE_SESSION_REGISTRY_DIR || path.join(configDir, "sessions");
119
121
  const bySession = new Map();
120
122
  let names = [];
@@ -136,7 +138,7 @@ function liveClaudeRegistrations(configDir) {
136
138
  return bySession;
137
139
  }
138
140
 
139
- function claudeState(registration) {
141
+ export function claudeState(registration) {
140
142
  if (!registration?.socketLive) return "offline";
141
143
  const status = String(registration.status || "").toLowerCase();
142
144
  if (["running", "busy", "working", "active"].includes(status)) return "active";