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 +86 -0
- package/overlay/preview-preload-source.cjs +7 -0
- package/overlay/preview-preload.cjs +6 -0
- package/overlay/preview-renderer.js +377 -5
- package/overlay/preview.html +76 -0
- package/package.json +1 -1
- package/src/claude-session-writer.js +1 -1
- package/src/mcp.js +6 -1
- package/src/runtime.js +19 -1
- package/src/session-controller.js +3 -1
- package/src/session-directory.js +4 -2
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();
|
package/overlay/preview.html
CHANGED
|
@@ -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.
|
|
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-
|
|
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: {
|
|
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
|
-
|
|
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(() => {
|
package/src/session-directory.js
CHANGED
|
@@ -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
|
-
|
|
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";
|