letagents 0.12.33 → 0.12.35

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.
Files changed (67) hide show
  1. package/README.md +0 -2
  2. package/dist/mcp/codex-session/app-server.js +16 -2
  3. package/dist/mcp/codex-session/session-start.js +1 -1
  4. package/dist/mcp/server/runtime/messages.js +18 -1
  5. package/dist/mcp/server/tools/tasks/verdict-tools.js +1 -1
  6. package/dist/mcp/server/tools/wake-rules.js +2 -1
  7. package/dist/shared/presence-change-channel.js +2 -0
  8. package/package.json +2 -2
  9. package/shared/agent-branch.d.mts +5 -0
  10. package/shared/agent-branch.mjs +28 -0
  11. package/shared/code-block.css +193 -0
  12. package/shared/code-highlighting.d.mts +109 -0
  13. package/shared/code-highlighting.mjs +345 -0
  14. package/shared/codex-owner-isolation.d.mts +34 -0
  15. package/shared/codex-owner-isolation.mjs +210 -0
  16. package/shared/message-link-preview-store.d.mts +10 -0
  17. package/shared/message-link-preview-store.mjs +77 -0
  18. package/shared/message-link-previews.d.mts +16 -0
  19. package/shared/message-link-previews.mjs +70 -0
  20. package/shared/message-pin-store.d.mts +20 -0
  21. package/shared/message-pin-store.mjs +102 -0
  22. package/shared/message-pins.d.mts +25 -0
  23. package/shared/message-pins.mjs +16 -0
  24. package/shared/message-reaction-store.d.mts +51 -0
  25. package/shared/message-reaction-store.mjs +290 -0
  26. package/shared/message-reactions.d.mts +73 -0
  27. package/shared/message-reactions.mjs +130 -0
  28. package/shared/message-search-controller.d.mts +38 -0
  29. package/shared/message-search-controller.mjs +99 -0
  30. package/shared/message-search.d.mts +46 -0
  31. package/shared/message-search.mjs +114 -0
  32. package/shared/paced-queue.d.mts +28 -0
  33. package/shared/paced-queue.mjs +159 -0
  34. package/shared/permission-review.d.mts +19 -0
  35. package/shared/permission-review.mjs +238 -15
  36. package/shared/room-message-urls.d.mts +25 -0
  37. package/shared/room-message-urls.mjs +119 -0
  38. package/shared/room-notification-client.d.mts +18 -0
  39. package/shared/room-notification-client.mjs +140 -0
  40. package/shared/room-notification-preferences.d.mts +17 -0
  41. package/shared/room-notification-preferences.mjs +34 -0
  42. package/shared/room-resource-invalidation.d.mts +5 -0
  43. package/shared/room-resource-invalidation.mjs +6 -0
  44. package/shared/room-turn-no-reply.d.mts +1 -0
  45. package/shared/room-turn-no-reply.mjs +1 -0
  46. package/shared/room-typing.d.mts +16 -0
  47. package/shared/room-typing.mjs +112 -0
  48. package/shared/room-unread-client.ts +128 -0
  49. package/shared/room-unread.d.mts +25 -0
  50. package/shared/room-unread.mjs +57 -0
  51. package/shared/room-unread.test.mjs +91 -0
  52. package/shared/rooms/RoomsDirectory.vue +1 -1
  53. package/shared/rooms/directory.ts +1 -0
  54. package/shared/slash-commands.d.mts +31 -0
  55. package/shared/slash-commands.mjs +48 -0
  56. package/shared/slash-commands.test.mjs +26 -0
  57. package/shared/ui/MessageReactionBar.vue +65 -0
  58. package/shared/ui/MessageReactionPicker.vue +291 -0
  59. package/shared/ui/MessageSearchResults.vue +106 -0
  60. package/shared/ui/PinnedMessages.vue +124 -0
  61. package/shared/ui/TypingIndicator.vue +22 -0
  62. package/shared/ui/WakeRulesPanel.vue +1 -0
  63. package/shared/ui/message-reactions.css +254 -0
  64. package/shared/ui/message-search.css +186 -0
  65. package/shared/ui/useComposerSlashCommands.ts +92 -0
  66. package/shared/wake-rules.d.mts +8 -3
  67. package/shared/wake-rules.mjs +20 -4
@@ -0,0 +1,345 @@
1
+ export const CANONICAL_LANGUAGES = Object.freeze([
2
+ 'javascript',
3
+ 'typescript',
4
+ 'python',
5
+ 'bash',
6
+ 'json',
7
+ 'yaml',
8
+ 'xml',
9
+ 'css',
10
+ 'sql',
11
+ 'diff',
12
+ 'rust',
13
+ 'go',
14
+ 'c',
15
+ 'cpp',
16
+ 'java',
17
+ 'csharp',
18
+ 'dockerfile',
19
+ 'ruby',
20
+ 'php',
21
+ 'swift',
22
+ 'kotlin',
23
+ 'ini',
24
+ ]);
25
+
26
+ const CANONICAL_SET = new Set(CANONICAL_LANGUAGES);
27
+
28
+ export const LANGUAGE_ALIASES = new Map([
29
+ // JavaScript
30
+ ['js', 'javascript'],
31
+ ['jsx', 'javascript'],
32
+ ['mjs', 'javascript'],
33
+ ['cjs', 'javascript'],
34
+ // TypeScript
35
+ ['ts', 'typescript'],
36
+ ['tsx', 'typescript'],
37
+ ['mts', 'typescript'],
38
+ ['cts', 'typescript'],
39
+ // Python
40
+ ['py', 'python'],
41
+ // Bash / Shell
42
+ ['sh', 'bash'],
43
+ ['zsh', 'bash'],
44
+ ['shell', 'bash'],
45
+ // YAML
46
+ ['yml', 'yaml'],
47
+ // XML / HTML / SVG
48
+ ['html', 'xml'],
49
+ ['xhtml', 'xml'],
50
+ ['svg', 'xml'],
51
+ // Rust
52
+ ['rs', 'rust'],
53
+ // Go
54
+ ['golang', 'go'],
55
+ // C / C++
56
+ ['c++', 'cpp'],
57
+ ['cc', 'cpp'],
58
+ ['cxx', 'cpp'],
59
+ ['hpp', 'cpp'],
60
+ ['h', 'c'],
61
+ // C#
62
+ ['cs', 'csharp'],
63
+ // Dockerfile
64
+ ['docker', 'dockerfile'],
65
+ // Ruby
66
+ ['rb', 'ruby'],
67
+ // Kotlin
68
+ ['kt', 'kotlin'],
69
+ ['kts', 'kotlin'],
70
+ // INI / TOML
71
+ ['toml', 'ini'],
72
+ ]);
73
+
74
+ export const MAX_HIGHLIGHT_CHARS = 5_000;
75
+ export const MAX_HIGHLIGHT_LINES = 250;
76
+ export const MAX_HIGHLIGHT_LINE_LENGTH = 1_000;
77
+ export const MAX_CACHE_ENTRIES = 500;
78
+ export const MAX_CACHE_CHARS = 2_000_000;
79
+
80
+ export const DEFAULT_TIME_BUDGET_MS = 250;
81
+ export const DEFAULT_TIME_WINDOW_MS = 1_000;
82
+
83
+ export function escapeHtml(value) {
84
+ return String(value ?? '')
85
+ .replace(/&/g, '&')
86
+ .replace(/</g, '&lt;')
87
+ .replace(/>/g, '&gt;')
88
+ .replace(/"/g, '&quot;')
89
+ .replace(/'/g, '&#39;');
90
+ }
91
+
92
+ export function escapeAttribute(value) {
93
+ return String(value ?? '')
94
+ .replace(/&/g, '&amp;')
95
+ .replace(/"/g, '&quot;')
96
+ .replace(/'/g, '&#39;')
97
+ .replace(/</g, '&lt;')
98
+ .replace(/>/g, '&gt;');
99
+ }
100
+
101
+ export function resolveLanguage(rawTag) {
102
+ if (!rawTag || typeof rawTag !== 'string') return null;
103
+ const normalized = rawTag.trim().toLowerCase();
104
+ if (!normalized) return null;
105
+
106
+ if (CANONICAL_SET.has(normalized)) {
107
+ return {
108
+ canonical: normalized,
109
+ label: normalized,
110
+ };
111
+ }
112
+
113
+ const aliasTarget = LANGUAGE_ALIASES.get(normalized);
114
+ if (aliasTarget && CANONICAL_SET.has(aliasTarget)) {
115
+ return {
116
+ canonical: aliasTarget,
117
+ label: normalized,
118
+ };
119
+ }
120
+
121
+ return null;
122
+ }
123
+
124
+ export class CodeHighlightCache {
125
+ constructor(maxEntries = MAX_CACHE_ENTRIES, maxChars = MAX_CACHE_CHARS) {
126
+ this.maxEntries = maxEntries;
127
+ this.maxChars = maxChars;
128
+ this.map = new Map();
129
+ this.currentChars = 0;
130
+ }
131
+
132
+ get(key) {
133
+ if (!this.map.has(key)) return undefined;
134
+ const value = this.map.get(key);
135
+ this.map.delete(key);
136
+ this.map.set(key, value);
137
+ return value;
138
+ }
139
+
140
+ set(key, value) {
141
+ const entryChars = key.length + value.length;
142
+ if (this.map.has(key)) {
143
+ const oldVal = this.map.get(key);
144
+ this.currentChars -= (key.length + oldVal.length);
145
+ this.map.delete(key);
146
+ }
147
+ this.currentChars += entryChars;
148
+ this.map.set(key, value);
149
+ while (this.map.size > this.maxEntries || this.currentChars > this.maxChars) {
150
+ const oldestKey = this.map.keys().next().value;
151
+ if (oldestKey === undefined) break;
152
+ const oldestVal = this.map.get(oldestKey);
153
+ this.currentChars -= (oldestKey.length + oldestVal.length);
154
+ this.map.delete(oldestKey);
155
+ }
156
+ }
157
+
158
+ clear() {
159
+ this.map.clear();
160
+ this.currentChars = 0;
161
+ }
162
+
163
+ get size() {
164
+ return this.map.size;
165
+ }
166
+
167
+ get totalChars() {
168
+ return this.currentChars;
169
+ }
170
+ }
171
+
172
+ export function createCodeHighlighter(options) {
173
+ const highlight = options?.highlight;
174
+ const now = options?.now ?? (() => (typeof performance !== 'undefined' ? performance.now() : Date.now()));
175
+ const maxBudgetMs = options?.maxBudgetMs ?? DEFAULT_TIME_BUDGET_MS;
176
+ const windowMs = options?.windowMs ?? DEFAULT_TIME_WINDOW_MS;
177
+
178
+ const cache = new CodeHighlightCache();
179
+
180
+ // Sliding window execution time tracker
181
+ // Worst-case main-thread freeze per second:
182
+ // An attacker sending 200 distinct worst-case blocks in a single message can trigger
183
+ // at most one block that starts when spentTime < 250ms (e.g. at 249ms plus one block
184
+ // taking ~60ms), bounding synchronous computation to ~310ms per 1,000ms sliding window.
185
+ // Once the 250ms budget is reached, subsequent blocks in that window immediately
186
+ // fall back to plain escaped text (<0.01ms each) without caching the fallback.
187
+ const budgetEntries = [];
188
+
189
+ function getSpentTime(currentTime) {
190
+ const cutoff = currentTime - windowMs;
191
+ while (budgetEntries.length > 0 && budgetEntries[0].timestamp <= cutoff) {
192
+ budgetEntries.shift();
193
+ }
194
+ let total = 0;
195
+ for (let i = 0; i < budgetEntries.length; i++) {
196
+ total += budgetEntries[i].duration;
197
+ }
198
+ return total;
199
+ }
200
+
201
+ function renderCodeBlock(code, rawTag) {
202
+ const resolved = resolveLanguage(rawTag);
203
+ const codeString = String(code ?? '');
204
+
205
+ let exceedsSize = codeString.length > MAX_HIGHLIGHT_CHARS;
206
+ if (!exceedsSize) {
207
+ let lineCount = 1;
208
+ let currentLineLen = 0;
209
+ for (let i = 0; i < codeString.length; i++) {
210
+ const ch = codeString.charCodeAt(i);
211
+ if (ch === 10) {
212
+ lineCount++;
213
+ if (lineCount > MAX_HIGHLIGHT_LINES) {
214
+ exceedsSize = true;
215
+ break;
216
+ }
217
+ currentLineLen = 0;
218
+ } else {
219
+ currentLineLen++;
220
+ if (currentLineLen > MAX_HIGHLIGHT_LINE_LENGTH) {
221
+ exceedsSize = true;
222
+ break;
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ let codeHtml;
229
+ if (!resolved || exceedsSize || typeof highlight !== 'function') {
230
+ codeHtml = escapeHtml(codeString);
231
+ } else {
232
+ const cacheKey = `${resolved.canonical}:${codeString}`;
233
+ const cached = cache.get(cacheKey);
234
+ if (cached !== undefined) {
235
+ // Cache hit: costs nothing against the sliding time budget
236
+ codeHtml = cached;
237
+ } else {
238
+ const currentTime = now();
239
+ const spent = getSpentTime(currentTime);
240
+ if (spent >= maxBudgetMs) {
241
+ // Budget exhausted: fall back to plain escaped text, do NOT cache
242
+ codeHtml = escapeHtml(codeString);
243
+ } else {
244
+ const t0 = now();
245
+ try {
246
+ const highlighted = highlight(codeString, resolved.canonical);
247
+ codeHtml = typeof highlighted === 'string' ? highlighted : escapeHtml(codeString);
248
+ } catch {
249
+ codeHtml = escapeHtml(codeString);
250
+ }
251
+ const t1 = now();
252
+ const duration = Math.max(0, t1 - t0);
253
+ budgetEntries.push({ timestamp: t0, duration });
254
+ cache.set(cacheKey, codeHtml);
255
+ }
256
+ }
257
+ }
258
+
259
+ const headerContent = resolved
260
+ ? `<span class="fenced-code-block-lang" data-lang="${escapeAttribute(resolved.label)}"></span><button type="button" class="fenced-code-block-copy" data-code-copy aria-label="Copy code"></button>`
261
+ : `<button type="button" class="fenced-code-block-copy" data-code-copy aria-label="Copy code"></button>`;
262
+
263
+ const langClass = resolved ? ` language-${escapeAttribute(resolved.label)}` : '';
264
+
265
+ return `<div class="fenced-code-block"><div class="fenced-code-block-header">${headerContent}</div><pre><code class="fenced-code-block-content${langClass}">${codeHtml}</code></pre></div>`;
266
+ }
267
+
268
+ return {
269
+ renderCodeBlock,
270
+ cache,
271
+ resolveLanguage,
272
+ };
273
+ }
274
+
275
+ export async function executeCodeBlockCopy(button, textToCopy, copyFn, copyTimers = new WeakMap()) {
276
+ let success = false;
277
+ if (textToCopy !== null && typeof textToCopy === 'string') {
278
+ try {
279
+ success = Boolean(await copyFn(textToCopy));
280
+ } catch {
281
+ success = false;
282
+ }
283
+ }
284
+
285
+ // Clear previous timer AFTER the await to avoid timer races on rapid clicks
286
+ const prevTimer = copyTimers.get(button);
287
+ if (prevTimer !== undefined) {
288
+ clearTimeout(prevTimer);
289
+ copyTimers.delete(button);
290
+ }
291
+
292
+ if (!button.isConnected) return { success, timerId: null };
293
+
294
+ if (success) {
295
+ button.setAttribute('data-copied', 'true');
296
+ button.setAttribute('aria-label', 'Copied to clipboard');
297
+ } else {
298
+ button.setAttribute('data-copied', 'error');
299
+ button.setAttribute('aria-label', 'Failed to copy to clipboard');
300
+ }
301
+
302
+ const timerId = setTimeout(() => {
303
+ if (copyTimers.get(button) === timerId) {
304
+ copyTimers.delete(button);
305
+ }
306
+ if (button.isConnected) {
307
+ button.removeAttribute('data-copied');
308
+ button.setAttribute('aria-label', 'Copy code');
309
+ }
310
+ }, 2000);
311
+
312
+ copyTimers.set(button, timerId);
313
+ return { success, timerId };
314
+ }
315
+
316
+ export function setupCodeBlockCopyListener(copyFn) {
317
+ if (typeof document === 'undefined') return () => {};
318
+
319
+ const copyTimers = new WeakMap();
320
+
321
+ async function handleCopyClick(event) {
322
+ const target = event?.target;
323
+ const button = target && typeof target.closest === 'function' ? target.closest('[data-code-copy]') : null;
324
+ if (!button) return;
325
+
326
+ // Prevent default button actions, but do NOT stop propagation so outside-click
327
+ // listeners (e.g. popover dismissal) still see the event.
328
+ event.preventDefault();
329
+
330
+ const container = button.closest('.fenced-code-block');
331
+ const codeEl = container?.querySelector('pre code.fenced-code-block-content') || container?.querySelector('code');
332
+ if (!codeEl) {
333
+ await executeCodeBlockCopy(button, null, copyFn, copyTimers);
334
+ return;
335
+ }
336
+
337
+ const textToCopy = codeEl.textContent ?? '';
338
+ await executeCodeBlockCopy(button, textToCopy, copyFn, copyTimers);
339
+ }
340
+
341
+ document.addEventListener('click', handleCopyClick, true);
342
+ return () => {
343
+ document.removeEventListener('click', handleCopyClick, true);
344
+ };
345
+ }
@@ -0,0 +1,34 @@
1
+ export const CODEX_OWNER_FEATURE_OVERRIDES: readonly string[];
2
+ export const LETAGENTS_MCP_SERVER_NAME: "letagents";
3
+
4
+ export type CodexMcpListRunner = (
5
+ codexBin: string,
6
+ args: string[],
7
+ options: { cwd?: string; env: NodeJS.ProcessEnv },
8
+ ) => Promise<string>;
9
+
10
+ export function codexHomeDirectory(env: NodeJS.ProcessEnv): string;
11
+ export function codexPersonalSkillFiles(env: NodeJS.ProcessEnv): Promise<string[]>;
12
+ export function codexSkillDisableOverride(skillFiles: readonly string[]): string | null;
13
+ export function codexMcpServerDisableOverride(serverNames: readonly string[]): string | null;
14
+ export const runCodexMcpList: CodexMcpListRunner;
15
+ export type CodexMcpServerEntry = { name: string; enabled?: boolean; transport?: unknown; [key: string]: unknown };
16
+ export function listCodexMcpServers(
17
+ codexBin: string,
18
+ options: { cwd?: string; env: NodeJS.ProcessEnv; configOverrides: readonly string[] },
19
+ run?: CodexMcpListRunner,
20
+ ): Promise<CodexMcpServerEntry[]>;
21
+ export function assertProjectKeepsLetAgentsServer(
22
+ inProject: readonly CodexMcpServerEntry[],
23
+ outsideProject: readonly CodexMcpServerEntry[],
24
+ ): void;
25
+ export function listCodexMcpServerNames(
26
+ codexBin: string,
27
+ options: { cwd?: string; env: NodeJS.ProcessEnv; configOverrides: readonly string[] },
28
+ run?: CodexMcpListRunner,
29
+ ): Promise<string[]>;
30
+ export function codexOwnerIsolationOverrides(
31
+ codexBin: string,
32
+ options: { cwd?: string; env: NodeJS.ProcessEnv; configOverrides?: readonly string[] },
33
+ run?: CodexMcpListRunner,
34
+ ): Promise<string[]>;
@@ -0,0 +1,210 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readdir, realpath, stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join, parse } from "node:path";
5
+
6
+ // A Codex app-server that LetAgents launches works for a room, not as the
7
+ // owner, yet it shares the owner's CODEX_HOME so Codex sign-in keeps working.
8
+ // These launch overrides turn the owner's extensions off for that launch only.
9
+ // The owner's config, auth and session history are not modified, and the
10
+ // project's own skills and AGENTS.md still load. Codex has no launch switch
11
+ // for its global AGENTS.md or its saved command rules, so those still reach
12
+ // the agent. Shared by the desktop and the published MCP package, so it uses
13
+ // nothing but Node.
14
+
15
+ /** Plugins (with their MCP servers and skills), app connectors, computer and browser use, hooks, memories, notifier. */
16
+ export const CODEX_OWNER_FEATURE_OVERRIDES = Object.freeze([
17
+ "features.plugins=false",
18
+ "features.apps=false",
19
+ "features.computer_use=false",
20
+ "features.browser_use=false",
21
+ "features.browser_use_external=false",
22
+ "features.hooks=false",
23
+ "features.memories=false",
24
+ "notify=[]",
25
+ ]);
26
+
27
+ /** The room's own MCP server, the one server a LetAgents-launched Codex keeps. */
28
+ export const LETAGENTS_MCP_SERVER_NAME = "letagents";
29
+
30
+ // `codex mcp list` checks OAuth for HTTP servers, which can take seconds when
31
+ // one is unreachable. A launch waits this long at most, then fails.
32
+ const MCP_LIST_TIMEOUT_MS = 10_000;
33
+ // Codex finds a SKILL.md at most six directories below a skill root.
34
+ const MAX_SKILL_DEPTH = 6;
35
+ const MAX_SKILL_FILES = 1_000;
36
+ const MAX_SKILL_DIRECTORIES = 10_000;
37
+
38
+ export function codexHomeDirectory(env) {
39
+ return env.CODEX_HOME?.trim() || join(env.HOME?.trim() || homedir(), ".codex");
40
+ }
41
+
42
+ /**
43
+ * SKILL.md files in Codex's user skill roots: CODEX_HOME/skills, apart from
44
+ * Codex's own bundled `.system` skills, and ~/.agents/skills. Both are
45
+ * searched to the same depth as Codex, including inside other skills.
46
+ */
47
+ export async function codexPersonalSkillFiles(env) {
48
+ const files = [];
49
+ const visited = new Set();
50
+ const walk = async (directory, depth) => {
51
+ if (depth > MAX_SKILL_DEPTH || files.length >= MAX_SKILL_FILES || visited.size >= MAX_SKILL_DIRECTORIES) return;
52
+ let entries;
53
+ try {
54
+ const canonical = await realpath(directory);
55
+ if (visited.has(canonical)) return;
56
+ visited.add(canonical);
57
+ entries = await readdir(directory);
58
+ } catch {
59
+ return;
60
+ }
61
+ for (const name of entries.sort()) {
62
+ if (files.length >= MAX_SKILL_FILES) return;
63
+ // Hidden entries include Codex's bundled `.system` skills.
64
+ if (name.startsWith(".")) continue;
65
+ const path = join(directory, name);
66
+ let entry;
67
+ try {
68
+ entry = await stat(path);
69
+ } catch {
70
+ continue;
71
+ }
72
+ if (entry.isDirectory()) {
73
+ await walk(path, depth + 1);
74
+ } else if (name === "SKILL.md" && entry.isFile()) {
75
+ files.push(path);
76
+ try {
77
+ const real = await realpath(path);
78
+ if (real !== path) files.push(real);
79
+ } catch {
80
+ // The listed path is enough when it cannot be resolved.
81
+ }
82
+ }
83
+ }
84
+ };
85
+ await walk(join(codexHomeDirectory(env), "skills"), 0);
86
+ await walk(join(env.HOME?.trim() || homedir(), ".agents", "skills"), 0);
87
+ return [...new Set(files)];
88
+ }
89
+
90
+ export function codexSkillDisableOverride(skillFiles) {
91
+ if (!skillFiles.length) return null;
92
+ const entries = skillFiles.map((path) => `{ path = ${JSON.stringify(path)}, enabled = false }`);
93
+ return `skills.config=[${entries.join(", ")}]`;
94
+ }
95
+
96
+ /**
97
+ * Codex's dotted -c paths split on every dot, even inside quotes, so a server
98
+ * name with a dot cannot be addressed that way. A parent-table override is
99
+ * merged into the configured servers and names each one exactly.
100
+ */
101
+ export function codexMcpServerDisableOverride(serverNames) {
102
+ const names = [...new Set(serverNames)].filter((name) => name !== LETAGENTS_MCP_SERVER_NAME).sort();
103
+ if (!names.length) return null;
104
+ return `mcp_servers={ ${names.map((name) => `${JSON.stringify(name)} = { enabled = false }`).join(", ")} }`;
105
+ }
106
+
107
+ export function runCodexMcpList(codexBin, args, options) {
108
+ return new Promise((resolve, reject) => {
109
+ const child = execFile(codexBin, args, {
110
+ ...(options.cwd ? { cwd: options.cwd } : {}),
111
+ env: options.env,
112
+ encoding: "utf8",
113
+ timeout: MCP_LIST_TIMEOUT_MS,
114
+ killSignal: "SIGKILL",
115
+ maxBuffer: 4 * 1024 * 1024,
116
+ }, (error, stdout) => {
117
+ if (error) reject(error);
118
+ else resolve(String(stdout));
119
+ });
120
+ child.stdin?.end();
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Every MCP server a launch would configure, as Codex itself resolves them
126
+ * from the owner's config and any trusted project config. Pass the launch's
127
+ * own overrides so the list matches what the app-server will see.
128
+ */
129
+ export async function listCodexMcpServers(codexBin, options, run = runCodexMcpList) {
130
+ let output;
131
+ try {
132
+ output = await run(codexBin, [
133
+ "mcp", "list", "--json",
134
+ ...options.configOverrides.flatMap((override) => ["-c", override]),
135
+ ], { cwd: options.cwd, env: options.env });
136
+ } catch (error) {
137
+ const detail = error instanceof Error ? error.message.split("\n")[0] : String(error);
138
+ throw new Error(
139
+ `Codex could not list its MCP servers, so LetAgents will not start it with the owner's own tools: ${detail}`,
140
+ );
141
+ }
142
+ let parsed = null;
143
+ try {
144
+ parsed = JSON.parse(output);
145
+ } catch {
146
+ // Rejected below.
147
+ }
148
+ if (!Array.isArray(parsed) || !parsed.every((entry) => entry && typeof entry === "object" && typeof entry.name === "string")) {
149
+ throw new Error("Codex returned an unreadable MCP server list, so LetAgents will not start it.");
150
+ }
151
+ return parsed;
152
+ }
153
+
154
+ export async function listCodexMcpServerNames(codexBin, options, run = runCodexMcpList) {
155
+ return (await listCodexMcpServers(codexBin, options, run)).map((entry) => entry.name);
156
+ }
157
+
158
+ function stableJson(value) {
159
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
160
+ if (value && typeof value === "object") {
161
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
162
+ }
163
+ return JSON.stringify(value ?? null);
164
+ }
165
+
166
+ /** How Codex would start a server: its transport (command, args, env, URL...) and whether it is on. */
167
+ function serverLaunchShape(entry) {
168
+ return entry ? stableJson({ enabled: entry.enabled ?? null, transport: entry.transport ?? null }) : null;
169
+ }
170
+
171
+ /**
172
+ * The LetAgents server keeps the owner's credentials, so a project must not
173
+ * change how it starts. Codex merges a trusted project's config into it key by
174
+ * key: a project could swap its args for its own script, or add NODE_OPTIONS,
175
+ * and run that with the owner's LETAGENTS_TOKEN. A launch override cannot
176
+ * remove merged keys, so such a launch is refused instead.
177
+ */
178
+ export function assertProjectKeepsLetAgentsServer(inProject, outsideProject) {
179
+ const find = (entries) => entries.find((entry) => entry.name === LETAGENTS_MCP_SERVER_NAME);
180
+ if (serverLaunchShape(find(inProject)) !== serverLaunchShape(find(outsideProject))) {
181
+ throw new Error(
182
+ "This project's Codex config changes the LetAgents MCP server, so LetAgents will not start Codex here. "
183
+ + "Remove [mcp_servers.letagents] from the project's .codex/config.toml, or stop trusting the project in Codex.",
184
+ );
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Every override a LetAgents launch adds: the features, each personal skill,
190
+ * and every MCP server but LetAgents. `cwd` is where the app-server starts and
191
+ * `configOverrides` are the launch's own (for example the room's server), used to
192
+ * list servers as the launch will see them. A launch that cannot list its
193
+ * servers, or whose project changes the LetAgents server, fails here.
194
+ */
195
+ export async function codexOwnerIsolationOverrides(codexBin, options, run = runCodexMcpList) {
196
+ const listOptions = {
197
+ env: options.env,
198
+ configOverrides: [...(options.configOverrides ?? []), ...CODEX_OWNER_FEATURE_OVERRIDES],
199
+ };
200
+ const [inProject, outsideProject, skillFiles] = await Promise.all([
201
+ listCodexMcpServers(codexBin, { ...listOptions, cwd: options.cwd }, run),
202
+ // Outside any project only the owner's own config applies.
203
+ options.cwd ? listCodexMcpServers(codexBin, { ...listOptions, cwd: parse(options.cwd).root || "/" }, run) : null,
204
+ codexPersonalSkillFiles(options.env),
205
+ ]);
206
+ if (outsideProject) assertProjectKeepsLetAgentsServer(inProject, outsideProject);
207
+ const skills = codexSkillDisableOverride(skillFiles);
208
+ const servers = codexMcpServerDisableOverride(inProject.map((entry) => entry.name));
209
+ return [...CODEX_OWNER_FEATURE_OVERRIDES, ...(skills ? [skills] : []), ...(servers ? [servers] : [])];
210
+ }
@@ -0,0 +1,10 @@
1
+ import type { LinkPreviewReference, MessageLinkPreview, MessageLinkPreviewsResponse } from "./message-link-previews.mjs";
2
+ export function createMessageLinkPreviewStore(options: {
3
+ load(references: LinkPreviewReference[]): Promise<MessageLinkPreviewsResponse>;
4
+ onChange?(): void; refreshDelayMs?: number;
5
+ }): {
6
+ track(message: { id: string; references: LinkPreviewReference[] }): () => void;
7
+ get(messageId: string): MessageLinkPreview[];
8
+ refresh(): Promise<void>;
9
+ reset(): void;
10
+ };
@@ -0,0 +1,77 @@
1
+ import { LINK_PREVIEW_BATCH_LIMIT, linkPreviewKey } from "./message-link-previews.mjs";
2
+
3
+ /** Separate from messages: one bounded read chain for the references rendered in a room. */
4
+ export function createMessageLinkPreviewStore({ load, onChange, refreshDelayMs = 50 }) {
5
+ let epoch = 0;
6
+ let tracked = new Map();
7
+ let previews = new Map();
8
+ let timer = null;
9
+ let reading = null;
10
+ function schedule() {
11
+ if (timer !== null) return;
12
+ timer = setTimeout(() => { timer = null; void refresh(); }, refreshDelayMs);
13
+ }
14
+ function track(message) {
15
+ if (!message.references.length) return () => {};
16
+ const token = Symbol();
17
+ tracked.set(token, message);
18
+ onChange?.();
19
+ if (message.references.some((ref) => !previews.has(linkPreviewKey(ref)))) schedule();
20
+ return () => {
21
+ tracked.delete(token);
22
+ const used = new Set([...tracked.values()].flatMap((entry) => entry.references.map(linkPreviewKey)));
23
+ for (const key of previews.keys()) if (!used.has(key)) previews.delete(key);
24
+ };
25
+ }
26
+ function get(messageId) {
27
+ const message = [...tracked.values()].find((entry) => entry.id === messageId);
28
+ return (message?.references ?? []).flatMap((ref) => previews.get(linkPreviewKey(ref)) ?? []);
29
+ }
30
+ function refresh() {
31
+ if (timer !== null) clearTimeout(timer);
32
+ timer = null;
33
+ if (reading) { reading.again = true; return reading.promise; }
34
+ const startedIn = epoch;
35
+ const cycle = { again: false, promise: null };
36
+ reading = cycle;
37
+ cycle.promise = (async () => {
38
+ do {
39
+ cycle.again = false;
40
+ const unique = new Map([...tracked.values()].flatMap((entry) => entry.references.map((ref) => [linkPreviewKey(ref), ref])));
41
+ const references = [...unique.values()];
42
+ for (let i = 0; i < references.length; i += LINK_PREVIEW_BATCH_LIMIT) {
43
+ if (startedIn !== epoch) return;
44
+ const batch = references.slice(i, i + LINK_PREVIEW_BATCH_LIMIT);
45
+ try {
46
+ const result = await load(batch);
47
+ if (startedIn !== epoch) return;
48
+ const values = new Map(result.previews.map((preview) => [linkPreviewKey(preview), preview]));
49
+ let changed = false;
50
+ const used = new Set([...tracked.values()].flatMap((entry) => entry.references.map(linkPreviewKey)));
51
+ for (const ref of batch) {
52
+ const key = linkPreviewKey(ref);
53
+ if (!used.has(key)) continue;
54
+ const next = values.get(key) ?? null;
55
+ if (JSON.stringify(previews.get(key) ?? null) !== JSON.stringify(next)) changed = true;
56
+ previews.set(key, next);
57
+ }
58
+ if (changed) onChange?.();
59
+ } catch {
60
+ // Retry on the next stream update, reconnect or registration.
61
+ }
62
+ }
63
+ } while (cycle.again && startedIn === epoch);
64
+ })().finally(() => { if (reading === cycle) reading = null; });
65
+ return cycle.promise;
66
+ }
67
+ function reset() {
68
+ epoch++;
69
+ if (timer !== null) clearTimeout(timer);
70
+ timer = null;
71
+ reading = null;
72
+ tracked = new Map();
73
+ previews = new Map();
74
+ onChange?.();
75
+ }
76
+ return { track, get, refresh, reset };
77
+ }
@@ -0,0 +1,16 @@
1
+ export const LINK_PREVIEW_BATCH_LIMIT: 50;
2
+ export const LINK_PREVIEW_MESSAGE_LIMIT: 3;
3
+ export interface LinkPreviewReference { kind: "pull" | "issue"; number: number }
4
+ export interface GitHubLinkReference extends LinkPreviewReference { repository: string; url: string }
5
+ export interface MessageLinkPreview extends GitHubLinkReference { title: string; state: "merged" | "closed" | "draft" | "open" }
6
+ export interface MessageLinkPreviewsResponse { room_id: string; previews: MessageLinkPreview[]; available?: boolean }
7
+ export function parseGitHubLinkReference(value: unknown): GitHubLinkReference | null;
8
+ export function normalizePreviewRepository(value: unknown): string | null;
9
+ export function linkPreviewKey(reference: LinkPreviewReference): string;
10
+ export function parseLinkPreviewReferences(value: unknown): LinkPreviewReference[] | null;
11
+ export function eligibleLinkPreviewReferences(urls: readonly string[], repository: string | null): LinkPreviewReference[];
12
+ export function linkPreviewState(state: unknown, metadata?: { merged?: unknown; draft?: unknown } | null): MessageLinkPreview["state"] | null;
13
+ export function linkPreviewPresentation(preview: MessageLinkPreview): {
14
+ kind: "pull-request" | "issue"; kindLabel: string; tone: "emerald" | "slate" | "amber" | "violet";
15
+ statusLabel: string; headline: string; detail: null; repository: string; taskId: null; url: string; urlLabel: string;
16
+ };