relay-companion 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/relay.js +262 -0
- package/overlay/inbox.html +1398 -0
- package/overlay/main.cjs +762 -0
- package/overlay/preload.cjs +37 -0
- package/overlay/sounds/tink.wav +0 -0
- package/package.json +25 -0
- package/src/claude-materializer.js +85 -0
- package/src/claude-session-writer.js +629 -0
- package/src/client.js +168 -0
- package/src/codex-app-server.js +120 -0
- package/src/codex-desktop.js +276 -0
- package/src/codex-session-writer.js +170 -0
- package/src/codex-state.js +114 -0
- package/src/config.js +62 -0
- package/src/host-json.js +14 -0
- package/src/host-paths.js +67 -0
- package/src/install.js +142 -0
- package/src/materializer.js +378 -0
- package/src/mcp.js +419 -0
- package/src/notifications.js +412 -0
- package/src/pinning.js +43 -0
- package/src/relay-briefing.js +344 -0
- package/src/runtime.js +1141 -0
- package/src/task-daemon.js +216 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
// Claude Desktop / Claude Code native session writer + title-repair loop.
|
|
2
|
+
// Ported faithfully from granular/tools/relay-companion/src/claude-session-writer.js.
|
|
3
|
+
//
|
|
4
|
+
// HOST MECHANICS ARE UNCHANGED: it forges a ~/.claude/projects/<key>/<id>.jsonl
|
|
5
|
+
// transcript (custom-title / ai-title / agent-name rows + one visible assistant
|
|
6
|
+
// turn), writes the Claude Desktop local_<id>.json session metadata, fires
|
|
7
|
+
// `claude://resume?session=...`, and then runs the post-import title-repair loop
|
|
8
|
+
// that fixes the "General coding session" rail-title bug by re-writing the saved
|
|
9
|
+
// metadata title (titleSource:"user") with retries and repairing duplicates.
|
|
10
|
+
//
|
|
11
|
+
// Adaptations (input side only):
|
|
12
|
+
// - paths -> ./host-paths.js
|
|
13
|
+
// - title/briefing come from the cloud row via ./relay-briefing.js instead of a
|
|
14
|
+
// granular packet's displayTitle/renderRelayBriefing.
|
|
15
|
+
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { relayRowTitle, renderRelayRowSeed } from "./relay-briefing.js";
|
|
21
|
+
import { claudeDesktopSessionsDir, claudeProjectsDir } from "./host-paths.js";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_CLAUDE_METADATA_MODEL = "claude-opus-4-8";
|
|
24
|
+
const CLAUDE_DESKTOP_LIVE_TITLE_LIMITATION = {
|
|
25
|
+
attempted: true,
|
|
26
|
+
supported: "best-effort",
|
|
27
|
+
reason: "claude-desktop-resume-import-uses-in-memory-session-cache",
|
|
28
|
+
detail:
|
|
29
|
+
"Relay writes Claude Code title rows and Claude Desktop metadata before import, then repairs saved metadata after import. Already-open Claude Desktop builds can still cache the imported row before the saved title is observed.",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function writeClaudeNativeSession({ row, cwd = process.cwd(), seed = renderRelayRowSeed(row) }) {
|
|
33
|
+
const sessionId = crypto.randomUUID();
|
|
34
|
+
const createdAt = new Date().toISOString();
|
|
35
|
+
const title = relayRowTitle(row);
|
|
36
|
+
// The human reads only `visible`. The agent-only `operatorNote` (real ids + the
|
|
37
|
+
// tool call to make) goes on a hidden system-meta row that Claude Desktop's
|
|
38
|
+
// transcript view does not render as a chat bubble (the same way the title rows
|
|
39
|
+
// above are non-rendered), so the human never sees ids or tool-call syntax.
|
|
40
|
+
const visible = String(seed?.visible || "").trim();
|
|
41
|
+
const operatorNote = String(seed?.operatorNote || "").trim();
|
|
42
|
+
const sessionDir = path.join(claudeProjectsDir(), claudeProjectKey(cwd));
|
|
43
|
+
const sessionPath = path.join(sessionDir, `${sessionId}.jsonl`);
|
|
44
|
+
const assistantUuid = crypto.randomUUID();
|
|
45
|
+
const operatorUuid = crypto.randomUUID();
|
|
46
|
+
const rows = [
|
|
47
|
+
{ type: "custom-title", customTitle: title, sessionId },
|
|
48
|
+
{ type: "ai-title", aiTitle: title, sessionId },
|
|
49
|
+
{ type: "agent-name", agentName: title, sessionId },
|
|
50
|
+
];
|
|
51
|
+
if (operatorNote) {
|
|
52
|
+
// Hidden agent-only channel: a system-meta row. isMeta marks it non-visible to
|
|
53
|
+
// the user; the agent still has it in context. Carries no chat role, so it is
|
|
54
|
+
// not rendered in the human-visible transcript.
|
|
55
|
+
rows.push({
|
|
56
|
+
type: "system",
|
|
57
|
+
subtype: "relay-operator-note",
|
|
58
|
+
isMeta: true,
|
|
59
|
+
isVisibleInTranscriptOnly: false,
|
|
60
|
+
content: operatorNote,
|
|
61
|
+
level: "info",
|
|
62
|
+
uuid: operatorUuid,
|
|
63
|
+
parentUuid: null,
|
|
64
|
+
timestamp: createdAt,
|
|
65
|
+
sessionId,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
rows.push(
|
|
69
|
+
{
|
|
70
|
+
parentUuid: operatorNote ? operatorUuid : null,
|
|
71
|
+
isSidechain: false,
|
|
72
|
+
message: {
|
|
73
|
+
model: "relay-companion",
|
|
74
|
+
id: claudeMessageId(),
|
|
75
|
+
type: "message",
|
|
76
|
+
role: "assistant",
|
|
77
|
+
content: [{ type: "text", text: visible }],
|
|
78
|
+
stop_reason: "end_turn",
|
|
79
|
+
stop_sequence: null,
|
|
80
|
+
usage: {},
|
|
81
|
+
},
|
|
82
|
+
requestId: relayRequestId(),
|
|
83
|
+
type: "assistant",
|
|
84
|
+
uuid: assistantUuid,
|
|
85
|
+
timestamp: createdAt,
|
|
86
|
+
userType: "external",
|
|
87
|
+
entrypoint: "relay-companion",
|
|
88
|
+
cwd,
|
|
89
|
+
sessionId,
|
|
90
|
+
version: "relay-companion",
|
|
91
|
+
gitBranch: process.env.GIT_BRANCH || null,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
type: "last-prompt",
|
|
95
|
+
lastPrompt: `Relay from ${row?.senderName || row?.sender?.name || row?.sender?.handle || "sender"}`,
|
|
96
|
+
leafUuid: assistantUuid,
|
|
97
|
+
sessionId,
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
102
|
+
writeJsonlAtomic(sessionPath, rows);
|
|
103
|
+
const desktopMetadataPath = writeClaudeDesktopSessionMetadata({ sessionId, title, cwd, createdAt });
|
|
104
|
+
if (process.env.RELAY_IMPORT_CLAUDE_DESKTOP !== "0") {
|
|
105
|
+
sleepSync(Number(process.env.RELAY_CLAUDE_PREIMPORT_DELAY_MS || 750));
|
|
106
|
+
}
|
|
107
|
+
const deepLink = claudeResumeDeepLink(sessionId);
|
|
108
|
+
const desktopImport = importClaudeDesktopSession({ sessionId });
|
|
109
|
+
const desktopTitleBeforeRefocus = restoreClaudeDesktopSessionTitle({
|
|
110
|
+
sessionId,
|
|
111
|
+
title,
|
|
112
|
+
cwd,
|
|
113
|
+
createdAt,
|
|
114
|
+
metadataPath: desktopMetadataPath,
|
|
115
|
+
retries: desktopImport.attempted ? 10 : 1,
|
|
116
|
+
});
|
|
117
|
+
const desktopRefocus =
|
|
118
|
+
desktopImport.attempted && desktopImport.status === 0
|
|
119
|
+
? importClaudeDesktopSession({ sessionId })
|
|
120
|
+
: { attempted: false, reason: desktopImport.reason || "import-not-successful", deepLink };
|
|
121
|
+
const desktopTitle =
|
|
122
|
+
desktopRefocus.attempted && desktopRefocus.status === 0
|
|
123
|
+
? restoreClaudeDesktopSessionTitle({
|
|
124
|
+
sessionId,
|
|
125
|
+
title,
|
|
126
|
+
cwd,
|
|
127
|
+
createdAt,
|
|
128
|
+
metadataPath: desktopTitleBeforeRefocus.metadataPath || desktopMetadataPath,
|
|
129
|
+
retries: 10,
|
|
130
|
+
})
|
|
131
|
+
: desktopTitleBeforeRefocus;
|
|
132
|
+
const desktopRepair = repairClaudeDesktopRelaySessions({ sessionIds: [sessionId] });
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
sessionId,
|
|
136
|
+
title,
|
|
137
|
+
sessionPath,
|
|
138
|
+
desktopMetadataPath: desktopTitle.metadataPath || desktopMetadataPath,
|
|
139
|
+
deepLink,
|
|
140
|
+
desktopImport,
|
|
141
|
+
desktopTitleBeforeRefocus,
|
|
142
|
+
desktopTitle,
|
|
143
|
+
desktopRefocus,
|
|
144
|
+
desktopRepair,
|
|
145
|
+
desktopLiveTitle: CLAUDE_DESKTOP_LIVE_TITLE_LIMITATION,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function writeClaudeDesktopSessionMetadata({ sessionId, title, cwd, createdAt = new Date().toISOString() }) {
|
|
150
|
+
const groupDir = findClaudeDesktopSessionGroupDir();
|
|
151
|
+
if (!groupDir) return null;
|
|
152
|
+
const nowMs = Date.now();
|
|
153
|
+
const createdAtMs = Number.isFinite(Date.parse(createdAt)) ? Date.parse(createdAt) : nowMs;
|
|
154
|
+
const desktopSessionId = `local_${sessionId}`;
|
|
155
|
+
const metadataPath = path.join(groupDir, `${desktopSessionId}.json`);
|
|
156
|
+
const metadata = {
|
|
157
|
+
sessionId: desktopSessionId,
|
|
158
|
+
cliSessionId: sessionId,
|
|
159
|
+
cwd,
|
|
160
|
+
originCwd: cwd,
|
|
161
|
+
lastFocusedAt: nowMs,
|
|
162
|
+
createdAt: createdAtMs,
|
|
163
|
+
lastActivityAt: nowMs,
|
|
164
|
+
model: process.env.RELAY_CLAUDE_METADATA_MODEL || DEFAULT_CLAUDE_METADATA_MODEL,
|
|
165
|
+
effort: "high",
|
|
166
|
+
isArchived: false,
|
|
167
|
+
title,
|
|
168
|
+
titleSource: "user",
|
|
169
|
+
permissionMode: "default",
|
|
170
|
+
enabledMcpTools: {},
|
|
171
|
+
remoteMcpServersConfig: [],
|
|
172
|
+
chromePermissionMode: "skip_all_permission_checks",
|
|
173
|
+
completedTurns: 1,
|
|
174
|
+
bridgeSessionIds: [],
|
|
175
|
+
alwaysAllowedReasons: [],
|
|
176
|
+
sessionPermissionUpdates: [],
|
|
177
|
+
classifierSummaryEnabled: true,
|
|
178
|
+
};
|
|
179
|
+
writeJsonAtomic(metadataPath, metadata);
|
|
180
|
+
return metadataPath;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function restoreClaudeDesktopSessionTitle({
|
|
184
|
+
sessionId,
|
|
185
|
+
title,
|
|
186
|
+
cwd = process.cwd(),
|
|
187
|
+
createdAt = new Date().toISOString(),
|
|
188
|
+
metadataPath = null,
|
|
189
|
+
retries = 10,
|
|
190
|
+
delayMs = 250,
|
|
191
|
+
} = {}) {
|
|
192
|
+
if (!sessionId || !title) {
|
|
193
|
+
return { attempted: false, reason: "missing-session-or-title", metadataPath: metadataPath || null };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let currentPath = metadataPath;
|
|
197
|
+
let lastError = null;
|
|
198
|
+
let lastSuccess = null;
|
|
199
|
+
const totalAttempts = Math.max(1, retries);
|
|
200
|
+
for (let attempt = 0; attempt < totalAttempts; attempt += 1) {
|
|
201
|
+
if (attempt > 0 || totalAttempts > 1) sleepSync(delayMs);
|
|
202
|
+
currentPath = currentPath || claudeDesktopSessionMetadataPath(sessionId);
|
|
203
|
+
if (!currentPath) {
|
|
204
|
+
lastError = "no-claude-desktop-session-dir";
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const nowMs = Date.now();
|
|
210
|
+
const createdAtMs = Number.isFinite(Date.parse(createdAt)) ? Date.parse(createdAt) : nowMs;
|
|
211
|
+
const existing = fs.existsSync(currentPath) ? readJsonIfExists(currentPath, {}) : {};
|
|
212
|
+
writeJsonAtomic(currentPath, {
|
|
213
|
+
sessionId: `local_${sessionId}`,
|
|
214
|
+
cliSessionId: sessionId,
|
|
215
|
+
cwd,
|
|
216
|
+
originCwd: cwd,
|
|
217
|
+
lastFocusedAt: existing.lastFocusedAt || nowMs,
|
|
218
|
+
createdAt: existing.createdAt || createdAtMs,
|
|
219
|
+
lastActivityAt: existing.lastActivityAt || nowMs,
|
|
220
|
+
model: process.env.RELAY_CLAUDE_METADATA_MODEL || existing.model || DEFAULT_CLAUDE_METADATA_MODEL,
|
|
221
|
+
effort: existing.effort || "high",
|
|
222
|
+
isArchived: false,
|
|
223
|
+
permissionMode: existing.permissionMode || "default",
|
|
224
|
+
enabledMcpTools: existing.enabledMcpTools || {},
|
|
225
|
+
remoteMcpServersConfig: existing.remoteMcpServersConfig || [],
|
|
226
|
+
chromePermissionMode: existing.chromePermissionMode || "skip_all_permission_checks",
|
|
227
|
+
completedTurns: existing.completedTurns || 1,
|
|
228
|
+
bridgeSessionIds: existing.bridgeSessionIds || [],
|
|
229
|
+
alwaysAllowedReasons: existing.alwaysAllowedReasons || [],
|
|
230
|
+
sessionPermissionUpdates: existing.sessionPermissionUpdates || [],
|
|
231
|
+
classifierSummaryEnabled: existing.classifierSummaryEnabled ?? true,
|
|
232
|
+
...existing,
|
|
233
|
+
title,
|
|
234
|
+
titleSource: "user",
|
|
235
|
+
});
|
|
236
|
+
lastSuccess = {
|
|
237
|
+
attempted: true,
|
|
238
|
+
restored: true,
|
|
239
|
+
metadataPath: currentPath,
|
|
240
|
+
title,
|
|
241
|
+
titleSource: "user",
|
|
242
|
+
attempts: attempt + 1,
|
|
243
|
+
};
|
|
244
|
+
if (totalAttempts === 1) return lastSuccess;
|
|
245
|
+
} catch (error) {
|
|
246
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (lastSuccess) return { ...lastSuccess, attempts: totalAttempts };
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
attempted: true,
|
|
254
|
+
restored: false,
|
|
255
|
+
metadataPath: currentPath || null,
|
|
256
|
+
title,
|
|
257
|
+
reason: lastError || "unknown",
|
|
258
|
+
attempts: Math.max(1, retries),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function claudeResumeDeepLink(sessionId) {
|
|
263
|
+
return `claude://resume?session=${encodeURIComponent(sessionId)}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function importClaudeDesktopSession({ sessionId, activate = process.env.RELAY_ACTIVATE_CLAUDE === "1" } = {}) {
|
|
267
|
+
const deepLink = claudeResumeDeepLink(sessionId);
|
|
268
|
+
if (process.env.RELAY_IMPORT_CLAUDE_DESKTOP === "0") {
|
|
269
|
+
return { attempted: false, reason: "disabled", deepLink };
|
|
270
|
+
}
|
|
271
|
+
if (process.platform !== "darwin") {
|
|
272
|
+
return { attempted: false, reason: "unsupported-platform", deepLink };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const args = activate ? [deepLink] : ["-g", deepLink];
|
|
276
|
+
const result = spawnSync("open", args, { encoding: "utf8", timeout: 5000 });
|
|
277
|
+
return {
|
|
278
|
+
attempted: true,
|
|
279
|
+
deepLink,
|
|
280
|
+
command: "open",
|
|
281
|
+
args,
|
|
282
|
+
status: result.status,
|
|
283
|
+
signal: result.signal,
|
|
284
|
+
error: result.error ? result.error.message : null,
|
|
285
|
+
stdout: result.stdout || "",
|
|
286
|
+
stderr: result.stderr || "",
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function isClaudeNativeSessionImported(nativeSession) {
|
|
291
|
+
if (!nativeSession?.sessionId) return false;
|
|
292
|
+
const deepLink = nativeSession.deepLink || nativeSession.desktopImport?.deepLink || "";
|
|
293
|
+
if (!deepLink.startsWith("claude://resume?session=")) return false;
|
|
294
|
+
const desktopImport = nativeSession.desktopImport;
|
|
295
|
+
if (!desktopImport) return false;
|
|
296
|
+
if (desktopImport.attempted) return desktopImport.status === 0 && isClaudeDesktopTitleRestored(nativeSession);
|
|
297
|
+
return desktopImport.reason === "disabled" || desktopImport.reason === "unsupported-platform";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function ensureClaudeDesktopImported(nativeSession, { title, cwd, createdAt } = {}) {
|
|
301
|
+
if (!nativeSession?.sessionId) return nativeSession;
|
|
302
|
+
const nextTitle = title || nativeSession.title;
|
|
303
|
+
if (isClaudeNativeSessionImported(nativeSession)) return nativeSession;
|
|
304
|
+
const desktopImport =
|
|
305
|
+
nativeSession.desktopImport?.attempted && nativeSession.desktopImport.status === 0
|
|
306
|
+
? nativeSession.desktopImport
|
|
307
|
+
: importClaudeDesktopSession({ sessionId: nativeSession.sessionId });
|
|
308
|
+
const desktopTitleBeforeRefocus = restoreClaudeDesktopSessionTitle({
|
|
309
|
+
sessionId: nativeSession.sessionId,
|
|
310
|
+
title: nextTitle,
|
|
311
|
+
cwd: cwd || nativeSession.cwd || process.cwd(),
|
|
312
|
+
createdAt: createdAt || nativeSession.createdAt || new Date().toISOString(),
|
|
313
|
+
metadataPath: nativeSession.desktopMetadataPath || nativeSession.desktopTitle?.metadataPath || null,
|
|
314
|
+
retries: desktopImport.attempted ? 10 : 1,
|
|
315
|
+
});
|
|
316
|
+
const desktopRefocus =
|
|
317
|
+
desktopImport.attempted && desktopImport.status === 0
|
|
318
|
+
? importClaudeDesktopSession({ sessionId: nativeSession.sessionId })
|
|
319
|
+
: {
|
|
320
|
+
attempted: false,
|
|
321
|
+
reason: desktopImport.reason || "import-not-successful",
|
|
322
|
+
deepLink: claudeResumeDeepLink(nativeSession.sessionId),
|
|
323
|
+
};
|
|
324
|
+
const desktopTitle =
|
|
325
|
+
desktopRefocus.attempted && desktopRefocus.status === 0
|
|
326
|
+
? restoreClaudeDesktopSessionTitle({
|
|
327
|
+
sessionId: nativeSession.sessionId,
|
|
328
|
+
title: nextTitle,
|
|
329
|
+
cwd: cwd || nativeSession.cwd || process.cwd(),
|
|
330
|
+
createdAt: createdAt || nativeSession.createdAt || new Date().toISOString(),
|
|
331
|
+
metadataPath: desktopTitleBeforeRefocus.metadataPath || nativeSession.desktopMetadataPath || null,
|
|
332
|
+
retries: 10,
|
|
333
|
+
})
|
|
334
|
+
: desktopTitleBeforeRefocus;
|
|
335
|
+
const desktopRepair = repairClaudeDesktopRelaySessions({ sessionIds: [nativeSession.sessionId] });
|
|
336
|
+
return {
|
|
337
|
+
...nativeSession,
|
|
338
|
+
title: nextTitle,
|
|
339
|
+
deepLink: claudeResumeDeepLink(nativeSession.sessionId),
|
|
340
|
+
desktopMetadataPath: desktopTitle.metadataPath || nativeSession.desktopMetadataPath || null,
|
|
341
|
+
desktopImport,
|
|
342
|
+
desktopTitleBeforeRefocus,
|
|
343
|
+
desktopTitle,
|
|
344
|
+
desktopRefocus,
|
|
345
|
+
desktopRepair,
|
|
346
|
+
desktopLiveTitle: CLAUDE_DESKTOP_LIVE_TITLE_LIMITATION,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function setClaudeNativeSessionAttention(
|
|
351
|
+
nativeSession,
|
|
352
|
+
{ packetId, title = nativeSession?.title || null, state = "unread", nowMs = Date.now() } = {},
|
|
353
|
+
) {
|
|
354
|
+
const sessionId = nativeSession?.sessionId;
|
|
355
|
+
if (!sessionId) return { attempted: false, reason: "missing-session-id" };
|
|
356
|
+
const metadataPath = nativeSession.desktopMetadataPath || claudeDesktopSessionMetadataPath(sessionId);
|
|
357
|
+
if (!metadataPath || !fs.existsSync(metadataPath)) {
|
|
358
|
+
return { attempted: false, reason: "metadata-not-found", metadataPath: metadataPath || null };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const metadata = readJsonIfExists(metadataPath, null);
|
|
362
|
+
if (!metadata) return { attempted: false, reason: "metadata-unreadable", metadataPath };
|
|
363
|
+
|
|
364
|
+
const previousAttention = metadata.relayAttention || {};
|
|
365
|
+
const originalLastActivityAt =
|
|
366
|
+
previousAttention.originalLastActivityAt ?? (Number.isFinite(metadata.lastActivityAt) ? metadata.lastActivityAt : nowMs);
|
|
367
|
+
const originalLastFocusedAt =
|
|
368
|
+
previousAttention.originalLastFocusedAt ?? (Number.isFinite(metadata.lastFocusedAt) ? metadata.lastFocusedAt : null);
|
|
369
|
+
const timestamp = new Date(nowMs).toISOString();
|
|
370
|
+
const nextAttention = {
|
|
371
|
+
...previousAttention,
|
|
372
|
+
packetId: packetId || previousAttention.packetId || null,
|
|
373
|
+
title: title || previousAttention.title || metadata.title || null,
|
|
374
|
+
state,
|
|
375
|
+
originalLastActivityAt,
|
|
376
|
+
originalLastFocusedAt,
|
|
377
|
+
updatedAt: timestamp,
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const nextMetadata = {
|
|
381
|
+
...metadata,
|
|
382
|
+
isArchived: false,
|
|
383
|
+
relayAttention: nextAttention,
|
|
384
|
+
};
|
|
385
|
+
if (isRelayTitle(title)) {
|
|
386
|
+
nextMetadata.title = title;
|
|
387
|
+
nextMetadata.titleSource = "user";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (state === "unread") {
|
|
391
|
+
nextAttention.lastAppliedAt = nowMs;
|
|
392
|
+
nextMetadata.lastActivityAt = Math.max(nowMs, Number(metadata.lastActivityAt) || 0);
|
|
393
|
+
} else {
|
|
394
|
+
nextAttention.acknowledgedAt = timestamp;
|
|
395
|
+
nextMetadata.lastActivityAt = originalLastActivityAt;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
writeJsonAtomic(metadataPath, nextMetadata);
|
|
399
|
+
return {
|
|
400
|
+
attempted: true,
|
|
401
|
+
metadataPath,
|
|
402
|
+
sessionId,
|
|
403
|
+
state,
|
|
404
|
+
lastActivityAt: nextMetadata.lastActivityAt,
|
|
405
|
+
originalLastActivityAt,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function repairClaudeDesktopRelaySessions({ sessionIds = null } = {}) {
|
|
410
|
+
const groupDir = findClaudeDesktopSessionGroupDir();
|
|
411
|
+
if (!groupDir) return { attempted: false, reason: "no-claude-desktop-session-dir", repaired: [], archived: [] };
|
|
412
|
+
const requested = sessionIds ? new Set(sessionIds) : null;
|
|
413
|
+
const metadataFiles = childJsonFiles(groupDir)
|
|
414
|
+
.map((filePath) => ({ filePath, metadata: readJsonIfExists(filePath, null) }))
|
|
415
|
+
.filter((entry) => entry.metadata?.cliSessionId)
|
|
416
|
+
.filter((entry) => !requested || requested.has(entry.metadata.cliSessionId));
|
|
417
|
+
const byCliSession = new Map();
|
|
418
|
+
for (const entry of metadataFiles) {
|
|
419
|
+
const list = byCliSession.get(entry.metadata.cliSessionId) || [];
|
|
420
|
+
list.push(entry);
|
|
421
|
+
byCliSession.set(entry.metadata.cliSessionId, list);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const repaired = [];
|
|
425
|
+
const archived = [];
|
|
426
|
+
for (const [sessionId, entries] of byCliSession) {
|
|
427
|
+
const title = inferRelayTitleForClaudeSession(sessionId, entries);
|
|
428
|
+
if (!title) continue;
|
|
429
|
+
|
|
430
|
+
const canonicalSessionId = `local_${sessionId}`;
|
|
431
|
+
let canonical = entries.find((entry) => entry.metadata.sessionId === canonicalSessionId);
|
|
432
|
+
if (!canonical) {
|
|
433
|
+
const seed = entries[0];
|
|
434
|
+
const canonicalPath = path.join(groupDir, `${canonicalSessionId}.json`);
|
|
435
|
+
const metadata = {
|
|
436
|
+
...seed.metadata,
|
|
437
|
+
sessionId: canonicalSessionId,
|
|
438
|
+
cliSessionId: sessionId,
|
|
439
|
+
title,
|
|
440
|
+
titleSource: "user",
|
|
441
|
+
isArchived: false,
|
|
442
|
+
lastActivityAt: seed.metadata.lastActivityAt || Date.now(),
|
|
443
|
+
};
|
|
444
|
+
writeJsonAtomic(canonicalPath, metadata);
|
|
445
|
+
canonical = { filePath: canonicalPath, metadata };
|
|
446
|
+
entries.push(canonical);
|
|
447
|
+
repaired.push({ sessionId, metadataPath: canonicalPath, title });
|
|
448
|
+
}
|
|
449
|
+
const targets = canonical ? [canonical] : entries;
|
|
450
|
+
for (const entry of targets) {
|
|
451
|
+
if (entry.metadata.title === title && entry.metadata.titleSource === "user") continue;
|
|
452
|
+
writeJsonAtomic(entry.filePath, {
|
|
453
|
+
...entry.metadata,
|
|
454
|
+
sessionId: entry.metadata.sessionId || canonicalSessionId,
|
|
455
|
+
cliSessionId: sessionId,
|
|
456
|
+
title,
|
|
457
|
+
titleSource: "user",
|
|
458
|
+
isArchived: false,
|
|
459
|
+
lastActivityAt: entry.metadata.lastActivityAt || Date.now(),
|
|
460
|
+
});
|
|
461
|
+
appendClaudeSessionTitleRows({ sessionId, title, cwd: entry.metadata.cwd || entry.metadata.originCwd });
|
|
462
|
+
repaired.push({ sessionId, metadataPath: entry.filePath, title });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
for (const entry of entries) {
|
|
466
|
+
if (entry === canonical) continue;
|
|
467
|
+
const duplicateTitle = isRelayTitle(entry.metadata.title) ? entry.metadata.title : title;
|
|
468
|
+
if (entry.metadata.isArchived === true) continue;
|
|
469
|
+
writeJsonAtomic(entry.filePath, {
|
|
470
|
+
...entry.metadata,
|
|
471
|
+
title: duplicateTitle,
|
|
472
|
+
titleSource: entry.metadata.titleSource || "user",
|
|
473
|
+
isArchived: true,
|
|
474
|
+
});
|
|
475
|
+
archived.push({ sessionId, metadataPath: entry.filePath, title: duplicateTitle });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return { attempted: true, groupDir, repaired, archived };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function appendClaudeSessionTitleRows({ sessionId, title, cwd = null }) {
|
|
483
|
+
if (!sessionId || !title) return { appended: false, reason: "missing-session-or-title" };
|
|
484
|
+
const sessionPath = findClaudeSessionPath({ sessionId, cwd });
|
|
485
|
+
if (!sessionPath) return { appended: false, reason: "session-transcript-not-found" };
|
|
486
|
+
const timestamp = new Date().toISOString();
|
|
487
|
+
appendJsonlAtomic(sessionPath, [
|
|
488
|
+
{ type: "custom-title", customTitle: title, sessionId, uuid: crypto.randomUUID(), timestamp },
|
|
489
|
+
{ type: "ai-title", aiTitle: title, sessionId, uuid: crypto.randomUUID(), timestamp },
|
|
490
|
+
{ type: "agent-name", agentName: title, sessionId, uuid: crypto.randomUUID(), timestamp },
|
|
491
|
+
]);
|
|
492
|
+
return { appended: true, sessionPath, title };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function claudeProjectKey(cwd) {
|
|
496
|
+
return path.resolve(String(cwd || process.cwd())).replace(/[^0-9A-Za-z]/g, "-");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function findClaudeDesktopSessionGroupDir() {
|
|
500
|
+
const root = claudeDesktopSessionsDir();
|
|
501
|
+
if (!fs.existsSync(root)) return null;
|
|
502
|
+
const accountDirs = childDirectories(root);
|
|
503
|
+
for (const accountDir of accountDirs) {
|
|
504
|
+
const groupDirs = childDirectories(accountDir);
|
|
505
|
+
if (groupDirs.length) return groupDirs[0];
|
|
506
|
+
}
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function claudeDesktopSessionMetadataPath(sessionId) {
|
|
511
|
+
const groupDir = findClaudeDesktopSessionGroupDir();
|
|
512
|
+
if (!groupDir) return null;
|
|
513
|
+
return path.join(groupDir, `local_${sessionId}.json`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function inferRelayTitleForClaudeSession(sessionId, entries) {
|
|
517
|
+
for (const entry of entries) {
|
|
518
|
+
if (isRelayTitle(entry.metadata.title)) return entry.metadata.title;
|
|
519
|
+
}
|
|
520
|
+
const transcriptTitle = readRelayTitleFromClaudeTranscript(sessionId, entries[0]?.metadata?.cwd || entries[0]?.metadata?.originCwd);
|
|
521
|
+
if (transcriptTitle) return transcriptTitle;
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function readRelayTitleFromClaudeTranscript(sessionId, cwd = null) {
|
|
526
|
+
const sessionPath = findClaudeSessionPath({ sessionId, cwd });
|
|
527
|
+
if (!sessionPath) return null;
|
|
528
|
+
let latestTitle = null;
|
|
529
|
+
for (const line of fs.readFileSync(sessionPath, "utf8").split("\n")) {
|
|
530
|
+
if (!line.trim()) continue;
|
|
531
|
+
let row = null;
|
|
532
|
+
try {
|
|
533
|
+
row = JSON.parse(line);
|
|
534
|
+
} catch {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (isRelayTitle(row.customTitle)) latestTitle = row.customTitle;
|
|
538
|
+
if (isRelayTitle(row.aiTitle)) latestTitle = row.aiTitle;
|
|
539
|
+
if (isRelayTitle(row.agentName)) latestTitle = row.agentName;
|
|
540
|
+
const text = row.message?.content?.find?.((item) => item?.type === "text")?.text || "";
|
|
541
|
+
const heading = text.match(/^#\s*(🔁\s+(?:From|Task:)[^\n]+)/m)?.[1];
|
|
542
|
+
if (isRelayTitle(heading)) latestTitle = heading;
|
|
543
|
+
}
|
|
544
|
+
return latestTitle;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function findClaudeSessionPath({ sessionId, cwd = null }) {
|
|
548
|
+
if (cwd) {
|
|
549
|
+
const direct = path.join(claudeProjectsDir(), claudeProjectKey(cwd), `${sessionId}.jsonl`);
|
|
550
|
+
if (fs.existsSync(direct)) return direct;
|
|
551
|
+
}
|
|
552
|
+
const root = claudeProjectsDir();
|
|
553
|
+
if (!fs.existsSync(root)) return null;
|
|
554
|
+
for (const dir of childDirectories(root)) {
|
|
555
|
+
const candidate = path.join(dir, `${sessionId}.jsonl`);
|
|
556
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function isRelayTitle(value) {
|
|
562
|
+
return /^🔁\s+(From\s+.+:|Task:)/u.test(String(value || "").trim());
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function childDirectories(dir) {
|
|
566
|
+
return fs
|
|
567
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
568
|
+
.filter((entry) => entry.isDirectory())
|
|
569
|
+
.map((entry) => path.join(dir, entry.name))
|
|
570
|
+
.sort();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function childJsonFiles(dir) {
|
|
574
|
+
return fs
|
|
575
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
576
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
577
|
+
.map((entry) => path.join(dir, entry.name))
|
|
578
|
+
.sort();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function claudeMessageId() {
|
|
582
|
+
return `msg_01relay${crypto.randomBytes(18).toString("hex")}`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function relayRequestId() {
|
|
586
|
+
return `req_relay_${crypto.randomBytes(12).toString("hex")}`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function writeJsonlAtomic(filePath, rows) {
|
|
590
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
591
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
592
|
+
fs.writeFileSync(tmp, `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`, { mode: 0o600 });
|
|
593
|
+
fs.renameSync(tmp, filePath);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function writeJsonAtomic(filePath, value) {
|
|
597
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
598
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
599
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
600
|
+
fs.renameSync(tmp, filePath);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function appendJsonlAtomic(filePath, rows) {
|
|
604
|
+
fs.appendFileSync(filePath, `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`, { mode: 0o600 });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function readJsonIfExists(filePath, fallback) {
|
|
608
|
+
try {
|
|
609
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
610
|
+
} catch {
|
|
611
|
+
return fallback;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function isClaudeDesktopTitleRestored(nativeSession) {
|
|
616
|
+
const desktopImport = nativeSession.desktopImport;
|
|
617
|
+
if (!desktopImport?.attempted) return true;
|
|
618
|
+
const expectedTitle = nativeSession.title || nativeSession.desktopTitle?.title;
|
|
619
|
+
const metadataPath = nativeSession.desktopTitle?.metadataPath || nativeSession.desktopMetadataPath;
|
|
620
|
+
if (expectedTitle && metadataPath && fs.existsSync(metadataPath)) {
|
|
621
|
+
const metadata = readJsonIfExists(metadataPath, null);
|
|
622
|
+
return metadata?.title === expectedTitle && metadata?.titleSource === "user";
|
|
623
|
+
}
|
|
624
|
+
return nativeSession.desktopTitle?.restored === true;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function sleepSync(ms) {
|
|
628
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
629
|
+
}
|