letagents 0.12.34 → 0.12.36

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 (68) hide show
  1. package/README.md +0 -2
  2. package/dist/mcp/server/runtime/messages.js +18 -1
  3. package/dist/mcp/server/runtime/workspace-capture.js +16 -5
  4. package/dist/mcp/server/tools/supervised-room-turn.js +1 -1
  5. package/package.json +5 -3
  6. package/shared/activation-routing.d.mts +21 -3
  7. package/shared/activation-routing.mjs +37 -0
  8. package/shared/code-block.css +193 -0
  9. package/shared/code-highlighting.d.mts +109 -0
  10. package/shared/code-highlighting.mjs +345 -0
  11. package/shared/codex-owner-isolation.mjs +5 -3
  12. package/shared/codex-read-only-room-tools.d.mts +2 -0
  13. package/shared/codex-read-only-room-tools.mjs +24 -0
  14. package/shared/mcp-workspace-capture-worker.mjs +2 -1
  15. package/shared/message-link-preview-store.d.mts +10 -0
  16. package/shared/message-link-preview-store.mjs +77 -0
  17. package/shared/message-link-previews.d.mts +17 -0
  18. package/shared/message-link-previews.mjs +76 -0
  19. package/shared/message-pin-store.d.mts +20 -0
  20. package/shared/message-pin-store.mjs +102 -0
  21. package/shared/message-pins.d.mts +25 -0
  22. package/shared/message-pins.mjs +16 -0
  23. package/shared/message-reaction-store.d.mts +51 -0
  24. package/shared/message-reaction-store.mjs +290 -0
  25. package/shared/message-reactions.d.mts +73 -0
  26. package/shared/message-reactions.mjs +130 -0
  27. package/shared/message-search-controller.d.mts +38 -0
  28. package/shared/message-search-controller.mjs +99 -0
  29. package/shared/message-search.d.mts +46 -0
  30. package/shared/message-search.mjs +114 -0
  31. package/shared/room-message-urls.d.mts +25 -0
  32. package/shared/room-message-urls.mjs +119 -0
  33. package/shared/room-notification-client.d.mts +18 -0
  34. package/shared/room-notification-client.mjs +140 -0
  35. package/shared/room-notification-preferences.d.mts +17 -0
  36. package/shared/room-notification-preferences.mjs +34 -0
  37. package/shared/room-resource-invalidation.d.mts +5 -0
  38. package/shared/room-resource-invalidation.mjs +6 -0
  39. package/shared/room-settings.d.mts +16 -0
  40. package/shared/room-settings.mjs +14 -0
  41. package/shared/room-typing.d.mts +18 -0
  42. package/shared/room-typing.mjs +129 -0
  43. package/shared/room-unread-client.ts +128 -0
  44. package/shared/room-unread.d.mts +25 -0
  45. package/shared/room-unread.mjs +57 -0
  46. package/shared/room-unread.test.mjs +91 -0
  47. package/shared/rooms/RoomsDirectory.vue +1 -1
  48. package/shared/rooms/directory.ts +1 -0
  49. package/shared/slash-commands.d.mts +31 -0
  50. package/shared/slash-commands.mjs +48 -0
  51. package/shared/slash-commands.test.mjs +26 -0
  52. package/shared/ui/GitHubEventCard.vue +229 -0
  53. package/shared/ui/MessageReactionBar.vue +65 -0
  54. package/shared/ui/MessageReactionPicker.vue +291 -0
  55. package/shared/ui/MessageSearchResults.vue +106 -0
  56. package/shared/ui/PinnedMessages.vue +129 -0
  57. package/shared/ui/TypingIndicator.vue +134 -0
  58. package/shared/ui/message-reactions.css +254 -0
  59. package/shared/ui/message-search.css +186 -0
  60. package/shared/ui/room-agent-work.css +178 -0
  61. package/shared/ui/room-message-motion.ts +143 -0
  62. package/shared/ui/useComposerSlashCommands.ts +92 -0
  63. package/shared/ui/usePinnedMessageVisibility.ts +20 -0
  64. package/shared/ui/useRoomMessageMotion.ts +195 -0
  65. package/shared/ui/useSlidingTabIndicator.ts +72 -0
  66. package/shared/workspace-change-capture.mjs +3 -1
  67. package/shared/workspace-turn-capture.d.mts +4 -1
  68. package/shared/workspace-turn-capture.mjs +203 -6
@@ -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
+ }
@@ -9,8 +9,10 @@ import { join, parse } from "node:path";
9
9
  // The owner's config, auth and session history are not modified, and the
10
10
  // project's own skills and AGENTS.md still load. Codex has no launch switch
11
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.
12
+ // the agent. A matching saved rule lets a command out of Codex's sandbox, so
13
+ // the desktop gives an agent at a sandboxed access level a Codex home that has
14
+ // none (codex-agent-home.ts). Shared by the desktop and the published MCP
15
+ // package, so it uses nothing but Node.
14
16
 
15
17
  /** Plugins (with their MCP servers and skills), app connectors, computer and browser use, hooks, memories, notifier. */
16
18
  export const CODEX_OWNER_FEATURE_OVERRIDES = Object.freeze([
@@ -188,7 +190,7 @@ export function assertProjectKeepsLetAgentsServer(inProject, outsideProject) {
188
190
  /**
189
191
  * Every override a LetAgents launch adds: the features, each personal skill,
190
192
  * and every MCP server but LetAgents. `cwd` is where the app-server starts and
191
- * `configOverrides` are the launch's own (for example project trust), used to
193
+ * `configOverrides` are the launch's own (for example the room's server), used to
192
194
  * list servers as the launch will see them. A launch that cannot list its
193
195
  * servers, or whose project changes the LetAgents server, fails here.
194
196
  */
@@ -0,0 +1,2 @@
1
+ /** The room tools a Read-only Codex agent may use; every other room tool is refused for it. */
2
+ export const CODEX_READ_ONLY_ROOM_TOOLS: ReadonlySet<string>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The room tools a Read-only Codex agent may use, and the only ones.
3
+ *
4
+ * These read what the room says, and say something in it: a message, a
5
+ * status, visible reasoning, a question for a person, and where the final
6
+ * answer goes. Each takes room identifiers and text, never a folder. None
7
+ * changes the task board, joins or creates a room, submits a review, saves
8
+ * room memory, sets a wake rule, or inspects or writes a repository on this
9
+ * Mac. A tool is allowed only by being named here, so one the room server
10
+ * gains later is refused until someone decides about it.
11
+ *
12
+ * Two places keep the list, and both read it from here. Codex is told to run
13
+ * these without asking and to refuse the others, since a Read-only agent has
14
+ * nobody to ask. The background service refuses the others itself, where
15
+ * every room tool call of such an agent passes, so the limit does not rest on
16
+ * what Codex does with an approval mode.
17
+ */
18
+ export const CODEX_READ_ONLY_ROOM_TOOLS = new Set([
19
+ // What the room says.
20
+ "read_messages", "get_current_room", "get_room_guidelines", "get_room_memory", "get_human_requests",
21
+ "get_board", "get_board_settings", "list_board_intents", "list_wake_rules", "get_room_artifacts", "get_room_events",
22
+ // What the agent says in it.
23
+ "send_message", "send_thread_message", "post_status", "post_reasoning", "request_human_input", "set_reply_thread",
24
+ ]);
@@ -12,7 +12,8 @@ try {
12
12
  if (workerData.operation === 'begin') {
13
13
  parentPort.postMessage({ baseline: await captureWorkspaceTree(capture.workspace, identity) });
14
14
  } else {
15
- const pair = await captureWorkspacePair(capture.workspace, capture.base_revision, capture.baseline, `${identity}:${capture.preparation_id}`);
15
+ // The starting snapshot was taken on `base_revision`. With what the remotes held then, it tells a base update from the work.
16
+ const pair = await captureWorkspacePair(capture.workspace, capture.base_revision, capture.baseline, `${identity}:${capture.preparation_id}`, capture.base_revision, workerData.remoteTips ?? null);
16
17
  pair.contribution.summary = text;
17
18
  const { review, ...preview } = pair;
18
19
  const summary = { version: 3, recorded_state: 'completed', evidence_incomplete: true, elapsed_ms: null,
@@ -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,17 @@
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 excludeGitHubEventLink(urls: readonly string[], eventUrl: string | null | undefined): string[];
9
+ export function normalizePreviewRepository(value: unknown): string | null;
10
+ export function linkPreviewKey(reference: LinkPreviewReference): string;
11
+ export function parseLinkPreviewReferences(value: unknown): LinkPreviewReference[] | null;
12
+ export function eligibleLinkPreviewReferences(urls: readonly string[], repository: string | null): LinkPreviewReference[];
13
+ export function linkPreviewState(state: unknown, metadata?: { merged?: unknown; draft?: unknown } | null): MessageLinkPreview["state"] | null;
14
+ export function linkPreviewPresentation(preview: MessageLinkPreview): {
15
+ kind: "pull-request" | "issue"; kindLabel: string; tone: "emerald" | "slate" | "amber" | "violet";
16
+ statusLabel: string; headline: string; detail: null; repository: string; taskId: null; url: string; urlLabel: string;
17
+ };
@@ -0,0 +1,76 @@
1
+ export const LINK_PREVIEW_BATCH_LIMIT = 50;
2
+ export const LINK_PREVIEW_MESSAGE_LIMIT = 3;
3
+
4
+ export function parseGitHubLinkReference(value) {
5
+ if (typeof value !== "string" || value.length > 2048) return null;
6
+ try {
7
+ const url = new URL(value);
8
+ if (url.protocol !== "https:" || url.hostname !== "github.com" || url.port || url.username || url.password) return null;
9
+ const match = /^\/([\w.-]+)\/([\w.-]+)\/(pull|issues)\/([1-9]\d*)\/?$/.exec(url.pathname);
10
+ if (!match) return null;
11
+ const number = Number(match[4]);
12
+ if (!Number.isSafeInteger(number)) return null;
13
+ const repository = `${match[1]}/${match[2]}`.toLowerCase();
14
+ const kind = match[3] === "pull" ? "pull" : "issue";
15
+ return { repository, kind, number, url: `https://github.com/${repository}/${match[3]}/${number}` };
16
+ } catch { return null; }
17
+ }
18
+
19
+ /** The event card already represents its main PR/issue; keep any other links. */
20
+ export function excludeGitHubEventLink(urls, eventUrl) {
21
+ const represented = parseGitHubLinkReference(eventUrl);
22
+ return urls.filter(url => !represented || parseGitHubLinkReference(url)?.url !== represented.url);
23
+ }
24
+
25
+ export function normalizePreviewRepository(value) {
26
+ if (typeof value !== "string") return null;
27
+ const name = value.replace(/^https:\/\//i, "").replace(/^github\.com\//i, "").replace(/\/$/, "");
28
+ return /^[\w.-]+\/[\w.-]+$/.test(name) ? name.toLowerCase() : null;
29
+ }
30
+
31
+ export function linkPreviewKey(reference) { return `${reference.kind}:${reference.number}`; }
32
+
33
+ export function parseLinkPreviewReferences(value) {
34
+ if (!Array.isArray(value) || value.length > LINK_PREVIEW_BATCH_LIMIT) return null;
35
+ const references = new Map();
36
+ for (const ref of value) {
37
+ if (!ref || typeof ref !== "object" || Object.keys(ref).length !== 2
38
+ || !["pull", "issue"].includes(ref.kind) || !Number.isSafeInteger(ref.number) || ref.number <= 0) return null;
39
+ references.set(linkPreviewKey(ref), { kind: ref.kind, number: ref.number });
40
+ }
41
+ return [...references.values()];
42
+ }
43
+
44
+ export function eligibleLinkPreviewReferences(urls, repository) {
45
+ const repo = normalizePreviewRepository(repository);
46
+ if (!repo) return [];
47
+ const refs = new Map();
48
+ for (const url of urls) {
49
+ const ref = parseGitHubLinkReference(url);
50
+ if (!ref || ref.repository !== repo) continue;
51
+ refs.set(linkPreviewKey(ref), { kind: ref.kind, number: ref.number });
52
+ if (refs.size === LINK_PREVIEW_MESSAGE_LIMIT) break;
53
+ }
54
+ return [...refs.values()];
55
+ }
56
+
57
+ /** State is a last-observed snapshot, never a request to GitHub. */
58
+ export function linkPreviewState(state, metadata) {
59
+ if (state === "merged" || metadata?.merged === true) return "merged";
60
+ if (state === "closed") return "closed";
61
+ if (state === "draft" || state === "open" && metadata?.draft === true) return "draft";
62
+ return state === "open" ? "open" : null;
63
+ }
64
+
65
+ export function linkPreviewPresentation(preview) {
66
+ const pull = preview.kind === "pull";
67
+ return {
68
+ kind: pull ? "pull-request" : "issue",
69
+ kindLabel: pull ? "Pull request" : "Issue",
70
+ tone: preview.state === "merged" ? "emerald" : preview.state === "closed" ? "slate" : preview.state === "draft" ? "amber" : "violet",
71
+ statusLabel: preview.state,
72
+ headline: `#${preview.number} ${preview.title}`,
73
+ detail: null, repository: preview.repository, taskId: null,
74
+ url: preview.url, urlLabel: pull ? "Open pull request" : "Open issue",
75
+ };
76
+ }
@@ -0,0 +1,20 @@
1
+ import type { MessagePin, MessagePinsResponse } from "./message-pins.mjs";
2
+ export interface MessagePinState {
3
+ pins: MessagePin[];
4
+ available: boolean;
5
+ loading: boolean;
6
+ pending: string | null;
7
+ error: string | null;
8
+ }
9
+ export function createMessagePinStore<Context>(options: {
10
+ load(context: Context): Promise<MessagePinsResponse>;
11
+ mutate(context: Context, messageId: string, pinned: boolean): Promise<unknown>;
12
+ onChange?(state: MessagePinState): void;
13
+ onError?(message: string): void;
14
+ }): {
15
+ readonly state: MessagePinState;
16
+ reset(context?: Context | null): void;
17
+ refresh(): Promise<unknown>;
18
+ setPinned(messageId: string, pinned: boolean): Promise<unknown>;
19
+ dispose(): void;
20
+ };