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,412 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { webUrl } from "./config.js";
|
|
5
|
+
import { relayBinPath } from "./install.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_COMPANION_STATE = {
|
|
8
|
+
version: 1,
|
|
9
|
+
profile: {
|
|
10
|
+
name: "",
|
|
11
|
+
handle: "",
|
|
12
|
+
email: "",
|
|
13
|
+
inboxDir: "",
|
|
14
|
+
contactCardRoots: [],
|
|
15
|
+
transport: { type: "filesystem" },
|
|
16
|
+
},
|
|
17
|
+
contacts: [],
|
|
18
|
+
packets: {},
|
|
19
|
+
meetingNotes: {},
|
|
20
|
+
setup: {},
|
|
21
|
+
emailThreads: {},
|
|
22
|
+
chats: {},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function absoluteActionUrl(pathOrUrl) {
|
|
26
|
+
if (!pathOrUrl) return webUrl();
|
|
27
|
+
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
|
28
|
+
const path = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
|
|
29
|
+
return `${webUrl()}${path}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function notificationActionUrl(notification) {
|
|
33
|
+
return notification.actionUrl || absoluteActionUrl(notification.url);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function shellQuote(value) {
|
|
37
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function relayCliCommand(args) {
|
|
41
|
+
return [process.execPath, relayBinPath(), ...args].map(shellQuote).join(" ");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function relayAnswerCommand(taskId, messageId) {
|
|
45
|
+
return relayCliCommand(["answer-question", "--task", taskId, "--message", messageId, "--answer", "..."]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function actionForNotification(notification) {
|
|
49
|
+
if (notification.kind === "human_question") {
|
|
50
|
+
return {
|
|
51
|
+
type: "answer_question",
|
|
52
|
+
taskId: notification.taskId,
|
|
53
|
+
messageId: notification.messageId,
|
|
54
|
+
url: notificationActionUrl(notification),
|
|
55
|
+
command: relayAnswerCommand(notification.taskId, notification.messageId),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (notification.kind === "task_request") {
|
|
59
|
+
return {
|
|
60
|
+
type: "task_request",
|
|
61
|
+
taskId: notification.taskId,
|
|
62
|
+
participantId: notification.participantId,
|
|
63
|
+
url: notificationActionUrl(notification),
|
|
64
|
+
actions: [
|
|
65
|
+
{ id: "accept", label: "Accept", method: "accept_task_invitation" },
|
|
66
|
+
{ id: "reject", label: "Reject", method: "reject_task_invitation" },
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (notification.kind === "share_approval") {
|
|
71
|
+
return {
|
|
72
|
+
type: "review_share_approval",
|
|
73
|
+
taskId: notification.taskId,
|
|
74
|
+
url: notificationActionUrl(notification),
|
|
75
|
+
actions: [
|
|
76
|
+
{ id: "send", label: "Send", method: "approve_share" },
|
|
77
|
+
{ id: "decline", label: "Decline", method: "decline_share" },
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
type: "open",
|
|
83
|
+
url: notificationActionUrl(notification),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function participantForUser(task, user) {
|
|
88
|
+
return (task.participants || []).find((participant) => (
|
|
89
|
+
participant.userId === user?.id || participant.email === user?.email
|
|
90
|
+
));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function taskNotification(task, kind, title, body, url, extra = {}) {
|
|
94
|
+
const base = {
|
|
95
|
+
id: `${kind}:${task.id}:${task.updatedAt || ""}`,
|
|
96
|
+
kind,
|
|
97
|
+
title,
|
|
98
|
+
body,
|
|
99
|
+
url,
|
|
100
|
+
taskId: task.id,
|
|
101
|
+
createdAt: task.updatedAt || task.createdAt || null,
|
|
102
|
+
...extra,
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
...base,
|
|
106
|
+
action: actionForNotification(base),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildHumanNotifications({ user, tasks = [], relays = [], connectors = [] } = {}) {
|
|
111
|
+
const notifications = [];
|
|
112
|
+
for (const task of tasks) {
|
|
113
|
+
const participant = participantForUser(task, user);
|
|
114
|
+
if (
|
|
115
|
+
task.viewerRole === "participant" &&
|
|
116
|
+
participant &&
|
|
117
|
+
["created", "delivered"].includes(participant.invitationState) &&
|
|
118
|
+
task.state === "inviting"
|
|
119
|
+
) {
|
|
120
|
+
notifications.push(taskNotification(
|
|
121
|
+
task,
|
|
122
|
+
"task_request",
|
|
123
|
+
"Relay task request",
|
|
124
|
+
`${task.title}: ${task.objective}`,
|
|
125
|
+
`/app/tasks/${task.id}`,
|
|
126
|
+
{ participantId: participant.id },
|
|
127
|
+
));
|
|
128
|
+
}
|
|
129
|
+
if (task.pendingApprovalCount > 0) {
|
|
130
|
+
notifications.push(taskNotification(
|
|
131
|
+
task,
|
|
132
|
+
"share_approval",
|
|
133
|
+
"Relay approval needed",
|
|
134
|
+
`${task.pendingApprovalCount} outbound share approval${task.pendingApprovalCount === 1 ? "" : "s"} waiting for ${task.title}.`,
|
|
135
|
+
`/app/tasks/${task.id}`,
|
|
136
|
+
));
|
|
137
|
+
}
|
|
138
|
+
if (task.state === "completed") {
|
|
139
|
+
notifications.push(taskNotification(task, "task_completed", "Relay task completed", task.title, `/app/tasks/${task.id}`));
|
|
140
|
+
}
|
|
141
|
+
if (task.state === "cancelled" || task.state === "failed") {
|
|
142
|
+
notifications.push(taskNotification(task, `task_${task.state}`, `Relay task ${task.state}`, task.title, `/app/tasks/${task.id}`));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const relay of relays) {
|
|
147
|
+
if (!relay.requiresAnswer) continue;
|
|
148
|
+
const question = relay.message?.humanResponse?.question || relay.message?.bodyMarkdown || relay.task.title;
|
|
149
|
+
// A human_question is a relay_to_human message with humanResponse.mode=required_before_resume,
|
|
150
|
+
// which the contract defines as an agent asking its OWN human (mcp.js relay_to_human doc).
|
|
151
|
+
// Carry the message's real senderLabel (TaskMessage.senderLabel, protocol.ts) when present;
|
|
152
|
+
// otherwise it is the viewer's own agent, so "Your agent" — never the brand word "Relay".
|
|
153
|
+
const questionSender = String(relay.message?.senderLabel || "").trim() || "Your agent";
|
|
154
|
+
const notification = {
|
|
155
|
+
id: `human_question:${relay.message.id}:${relay.message.updatedAt || ""}`,
|
|
156
|
+
kind: "human_question",
|
|
157
|
+
title: "Relay question needs your answer",
|
|
158
|
+
body: question,
|
|
159
|
+
senderName: questionSender,
|
|
160
|
+
url: `/app/relays`,
|
|
161
|
+
taskId: relay.task.id,
|
|
162
|
+
messageId: relay.message.id,
|
|
163
|
+
quickReply: true,
|
|
164
|
+
createdAt: relay.message.updatedAt || relay.message.createdAt || null,
|
|
165
|
+
};
|
|
166
|
+
notifications.push({ ...notification, action: actionForNotification(notification) });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const connector of connectors) {
|
|
170
|
+
if (!["expired", "reconnect_required", "admin_consent_required", "error"].includes(connector.status)) continue;
|
|
171
|
+
const notification = {
|
|
172
|
+
id: `connector:${connector.provider}:${connector.status}:${connector.lastCheckedAt || ""}`,
|
|
173
|
+
kind: "connector_reauth",
|
|
174
|
+
title: `${connector.displayName} needs attention`,
|
|
175
|
+
body: connector.errorMessage || `${connector.displayName} is ${connector.status.replaceAll("_", " ")}.`,
|
|
176
|
+
url: "/app/connectors",
|
|
177
|
+
provider: connector.provider,
|
|
178
|
+
createdAt: connector.lastCheckedAt || null,
|
|
179
|
+
};
|
|
180
|
+
notifications.push({ ...notification, action: actionForNotification(notification) });
|
|
181
|
+
}
|
|
182
|
+
return notifications;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function freshNotifications(ledger, notifications) {
|
|
186
|
+
const seen = ledger.notifications || {};
|
|
187
|
+
return notifications.filter((notification) => !seen[notification.id]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function markNotificationsProcessed(ledger, notifications) {
|
|
191
|
+
ledger.notifications ||= {};
|
|
192
|
+
const processedAt = new Date().toISOString();
|
|
193
|
+
for (const notification of notifications) {
|
|
194
|
+
ledger.notifications[notification.id] = {
|
|
195
|
+
kind: notification.kind,
|
|
196
|
+
processedAt,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function companionHome() {
|
|
202
|
+
return process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function companionStatePath(dir = companionHome()) {
|
|
206
|
+
return path.join(dir, "state.json");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readCompanionState(statePath) {
|
|
210
|
+
try {
|
|
211
|
+
return {
|
|
212
|
+
...structuredClone(DEFAULT_COMPANION_STATE),
|
|
213
|
+
...JSON.parse(fs.readFileSync(statePath, "utf8")),
|
|
214
|
+
};
|
|
215
|
+
} catch {
|
|
216
|
+
return structuredClone(DEFAULT_COMPANION_STATE);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function writeCompanionState(statePath, state) {
|
|
221
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
|
|
222
|
+
const tmp = `${statePath}.${process.pid}.${Date.now()}.tmp`;
|
|
223
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
224
|
+
fs.renameSync(tmp, statePath);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function notificationUrgency(kind) {
|
|
228
|
+
if (kind === "human_question") return "blocking";
|
|
229
|
+
if (kind === "task_request" || kind === "share_approval") return "action_required";
|
|
230
|
+
if (kind === "connector_reauth") return "attention";
|
|
231
|
+
if (kind === "task_failed" || kind === "task_cancelled") return "problem";
|
|
232
|
+
return "normal";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function senderForNotification(notification) {
|
|
236
|
+
// Always prefer the real sender carried on the notification (e.g. the question's
|
|
237
|
+
// TaskMessage.senderLabel or the other person's name). Never surface the brand word
|
|
238
|
+
// "Relay" as if it were a person.
|
|
239
|
+
if (notification.senderName) return notification.senderName;
|
|
240
|
+
// A human_question is the viewer's own agent asking them (relay_to_human +
|
|
241
|
+
// humanResponse.mode=required_before_resume per the relay_to_human contract).
|
|
242
|
+
if (notification.kind === "human_question") return "Your agent";
|
|
243
|
+
// task_request / share_approval are workflow notices, not people; label them as the
|
|
244
|
+
// automation, not as "Relay" the person. (These are filtered out of the Relays list
|
|
245
|
+
// anyway via RELAY_HIDDEN_KINDS, but the staged senderName must still be coherent.)
|
|
246
|
+
if (notification.kind === "task_request") return "Your agent";
|
|
247
|
+
if (notification.kind === "share_approval") return "Your agent";
|
|
248
|
+
if (notification.kind === "connector_reauth") return "Relay connectors";
|
|
249
|
+
return "Someone";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function subjectForNotification(notification) {
|
|
253
|
+
if (notification.kind === "human_question" && notification.body) return notification.body;
|
|
254
|
+
if (notification.kind === "task_request" && notification.body) return notification.body;
|
|
255
|
+
if (notification.kind === "share_approval" && notification.body) return notification.body;
|
|
256
|
+
return notification.title || notification.body || "Relay";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function notificationSender(notification) {
|
|
260
|
+
return {
|
|
261
|
+
name: senderForNotification(notification),
|
|
262
|
+
handle: "relay",
|
|
263
|
+
email: "",
|
|
264
|
+
host: "relay",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function notificationRecipient() {
|
|
269
|
+
return {
|
|
270
|
+
name: "You",
|
|
271
|
+
handle: "you",
|
|
272
|
+
email: "",
|
|
273
|
+
ai: "codex",
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function packetMarkdownForNotification(notification) {
|
|
278
|
+
const lines = [
|
|
279
|
+
notification.body || notification.title || "Relay needs your attention.",
|
|
280
|
+
"",
|
|
281
|
+
"---",
|
|
282
|
+
`Relay attention item: ${notification.kind || "relay"}`,
|
|
283
|
+
];
|
|
284
|
+
if (notification.taskId) lines.push(`Task id: \`${notification.taskId}\``);
|
|
285
|
+
if (notification.messageId) lines.push(`Message id: \`${notification.messageId}\``);
|
|
286
|
+
if (notification.participantId) lines.push(`Participant id: \`${notification.participantId}\``);
|
|
287
|
+
if (notification.provider) lines.push(`Connector: \`${notification.provider}\``);
|
|
288
|
+
if (notification.kind === "human_question") {
|
|
289
|
+
lines.push("");
|
|
290
|
+
lines.push(
|
|
291
|
+
"This is a blocking Relay task question. Discuss it with the human, then call `relay_answer_human_question` with the task id, message id, and the human's answer.",
|
|
292
|
+
);
|
|
293
|
+
} else if (notification.kind === "task_request") {
|
|
294
|
+
lines.push("");
|
|
295
|
+
lines.push("This is a Relay task request. The human must accept or reject before their agent participates.");
|
|
296
|
+
} else if (notification.kind === "share_approval") {
|
|
297
|
+
lines.push("");
|
|
298
|
+
lines.push("This is a Relay share approval. No cross-person information has been sent yet.");
|
|
299
|
+
}
|
|
300
|
+
return lines.join("\n");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function writeNotificationPacketContent(notification, packet, statePath) {
|
|
304
|
+
const dir = path.join(path.dirname(statePath), "packets");
|
|
305
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
306
|
+
const contentPath = path.join(dir, `${packet.id}.json`);
|
|
307
|
+
const tmp = `${contentPath}.${process.pid}.${Date.now()}.tmp`;
|
|
308
|
+
fs.writeFileSync(tmp, `${JSON.stringify(packet, null, 2)}\n`, { mode: 0o600 });
|
|
309
|
+
fs.renameSync(tmp, contentPath);
|
|
310
|
+
return contentPath;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function relayPacketFromNotification(notification, { now = new Date().toISOString() } = {}) {
|
|
314
|
+
const subject = subjectForNotification(notification);
|
|
315
|
+
const sender = notificationSender(notification);
|
|
316
|
+
return {
|
|
317
|
+
schemaVersion: 1,
|
|
318
|
+
id: notification.id,
|
|
319
|
+
createdAt: notification.createdAt || now,
|
|
320
|
+
updatedAt: now,
|
|
321
|
+
state: "unread",
|
|
322
|
+
sender,
|
|
323
|
+
recipient: notificationRecipient(),
|
|
324
|
+
source: {
|
|
325
|
+
host: "relay",
|
|
326
|
+
surface: "relay_companion",
|
|
327
|
+
threadId: null,
|
|
328
|
+
cwd: process.cwd(),
|
|
329
|
+
},
|
|
330
|
+
kind: "message",
|
|
331
|
+
title: subject,
|
|
332
|
+
displayTitle: subject,
|
|
333
|
+
userInstructions: "",
|
|
334
|
+
includeDefaultTranscript: false,
|
|
335
|
+
briefingMarkdown: packetMarkdownForNotification(notification),
|
|
336
|
+
attachments: [],
|
|
337
|
+
delivery: {
|
|
338
|
+
transport: "relay_task",
|
|
339
|
+
targetSurfaces: ["codex", "claude_code"],
|
|
340
|
+
recipientInboxDir: "",
|
|
341
|
+
outboxPath: null,
|
|
342
|
+
inboxPath: null,
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function relayCompanionRowFromNotification(
|
|
348
|
+
notification,
|
|
349
|
+
{ now = new Date().toISOString(), contentPath = null } = {},
|
|
350
|
+
) {
|
|
351
|
+
const actionUrl = notificationActionUrl(notification);
|
|
352
|
+
const subject = subjectForNotification(notification);
|
|
353
|
+
return {
|
|
354
|
+
id: notification.id,
|
|
355
|
+
direction: "inbound",
|
|
356
|
+
state: "unread",
|
|
357
|
+
kind: "message",
|
|
358
|
+
// Default unknown notification kinds to a neutral "message", never "task_completed"
|
|
359
|
+
// (which would falsely mark an unknown row as a finished task). main.cjs readRelays
|
|
360
|
+
// also defaults to "task_completed"; staging a real kind here keeps that fallback unused.
|
|
361
|
+
relayNotificationKind: notification.kind || "message",
|
|
362
|
+
urgency: notificationUrgency(notification.kind),
|
|
363
|
+
senderName: senderForNotification(notification),
|
|
364
|
+
title: subject,
|
|
365
|
+
displayTitle: subject,
|
|
366
|
+
briefingMarkdown: packetMarkdownForNotification(notification),
|
|
367
|
+
bodyMarkdown: notification.body || notification.title || "",
|
|
368
|
+
createdAt: notification.createdAt || now,
|
|
369
|
+
updatedAt: now,
|
|
370
|
+
stagedAt: now,
|
|
371
|
+
actionUrl,
|
|
372
|
+
action: notification.action || actionForNotification(notification),
|
|
373
|
+
quickReply: Boolean(notification.quickReply),
|
|
374
|
+
taskId: notification.taskId || null,
|
|
375
|
+
participantId: notification.participantId || null,
|
|
376
|
+
messageId: notification.messageId || null,
|
|
377
|
+
provider: notification.provider || null,
|
|
378
|
+
contentPath,
|
|
379
|
+
filePath: contentPath,
|
|
380
|
+
attachments: [],
|
|
381
|
+
materializationDeferredReason: "relay_pill",
|
|
382
|
+
materializedSurfaces: { codex: false, claudeCode: false, claudeCowork: false },
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function stageRelayCompanionItem(notification, { statePath = companionStatePath() } = {}) {
|
|
387
|
+
const state = readCompanionState(statePath);
|
|
388
|
+
state.packets ||= {};
|
|
389
|
+
state.chats ||= {};
|
|
390
|
+
const existing = state.packets[notification.id] || {};
|
|
391
|
+
const packet = relayPacketFromNotification(notification);
|
|
392
|
+
const contentPath = writeNotificationPacketContent(notification, packet, statePath);
|
|
393
|
+
const item = {
|
|
394
|
+
...existing,
|
|
395
|
+
...relayCompanionRowFromNotification(notification, { contentPath }),
|
|
396
|
+
state: existing.state === "read" ? "read" : "unread",
|
|
397
|
+
};
|
|
398
|
+
state.packets[item.id] = item;
|
|
399
|
+
writeCompanionState(statePath, state);
|
|
400
|
+
return {
|
|
401
|
+
ok: true,
|
|
402
|
+
transport: "relay_companion",
|
|
403
|
+
statePath,
|
|
404
|
+
itemId: item.id,
|
|
405
|
+
actionUrl: item.actionUrl,
|
|
406
|
+
contentPath,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function defaultStageRelayCompanionItem(notification) {
|
|
411
|
+
return stageRelayCompanionItem(notification);
|
|
412
|
+
}
|
package/src/pinning.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Codex pinned-thread-ids writer. Ported faithfully from
|
|
2
|
+
// granular/tools/relay-companion/src/pinning.js (paths -> host-paths.js,
|
|
3
|
+
// writeJsonAtomic -> host-json.js). The open path pins the just-materialized
|
|
4
|
+
// Relay thread so Codex Desktop surfaces it; readPinnedThreadIds feeds the
|
|
5
|
+
// desktop refresh expression.
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { codexGlobalStatePath } from "./host-paths.js";
|
|
10
|
+
import { writeJsonAtomic } from "./host-json.js";
|
|
11
|
+
|
|
12
|
+
export function readPinnedThreadIds() {
|
|
13
|
+
const filePath = codexGlobalStatePath();
|
|
14
|
+
if (!fs.existsSync(filePath)) return [];
|
|
15
|
+
const state = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
16
|
+
return Array.isArray(state["pinned-thread-ids"]) ? state["pinned-thread-ids"] : [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writePinnedThreadIds(threadIds) {
|
|
20
|
+
if (!Array.isArray(threadIds)) throw new TypeError("threadIds must be an array");
|
|
21
|
+
const filePath = codexGlobalStatePath();
|
|
22
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
23
|
+
const state = fs.existsSync(filePath) ? JSON.parse(fs.readFileSync(filePath, "utf8")) : {};
|
|
24
|
+
state["pinned-thread-ids"] = Array.from(new Set(threadIds.filter(Boolean)));
|
|
25
|
+
writeJsonAtomic(filePath, state);
|
|
26
|
+
return state["pinned-thread-ids"];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function applyPinnedThreadPlan({ pinThreadIds = [], unpinThreadIds = [] }) {
|
|
30
|
+
const pinned = Array.from(new Set(pinThreadIds.filter(Boolean)));
|
|
31
|
+
const unpinned = new Set(unpinThreadIds.filter(Boolean));
|
|
32
|
+
const planned = new Set([...pinned, ...unpinned]);
|
|
33
|
+
const preserved = readPinnedThreadIds().filter((threadId) => !planned.has(threadId));
|
|
34
|
+
return writePinnedThreadIds([...pinned, ...preserved]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setThreadPinned(threadId, pinned) {
|
|
38
|
+
if (!threadId) throw new Error("threadId is required");
|
|
39
|
+
return applyPinnedThreadPlan({
|
|
40
|
+
pinThreadIds: pinned ? [threadId] : [],
|
|
41
|
+
unpinThreadIds: pinned ? [] : [threadId],
|
|
42
|
+
});
|
|
43
|
+
}
|