letagents 0.12.34 → 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 (50) hide show
  1. package/README.md +0 -2
  2. package/dist/mcp/server/runtime/messages.js +18 -1
  3. package/package.json +1 -1
  4. package/shared/code-block.css +193 -0
  5. package/shared/code-highlighting.d.mts +109 -0
  6. package/shared/code-highlighting.mjs +345 -0
  7. package/shared/codex-owner-isolation.mjs +1 -1
  8. package/shared/message-link-preview-store.d.mts +10 -0
  9. package/shared/message-link-preview-store.mjs +77 -0
  10. package/shared/message-link-previews.d.mts +16 -0
  11. package/shared/message-link-previews.mjs +70 -0
  12. package/shared/message-pin-store.d.mts +20 -0
  13. package/shared/message-pin-store.mjs +102 -0
  14. package/shared/message-pins.d.mts +25 -0
  15. package/shared/message-pins.mjs +16 -0
  16. package/shared/message-reaction-store.d.mts +51 -0
  17. package/shared/message-reaction-store.mjs +290 -0
  18. package/shared/message-reactions.d.mts +73 -0
  19. package/shared/message-reactions.mjs +130 -0
  20. package/shared/message-search-controller.d.mts +38 -0
  21. package/shared/message-search-controller.mjs +99 -0
  22. package/shared/message-search.d.mts +46 -0
  23. package/shared/message-search.mjs +114 -0
  24. package/shared/room-message-urls.d.mts +25 -0
  25. package/shared/room-message-urls.mjs +119 -0
  26. package/shared/room-notification-client.d.mts +18 -0
  27. package/shared/room-notification-client.mjs +140 -0
  28. package/shared/room-notification-preferences.d.mts +17 -0
  29. package/shared/room-notification-preferences.mjs +34 -0
  30. package/shared/room-resource-invalidation.d.mts +5 -0
  31. package/shared/room-resource-invalidation.mjs +6 -0
  32. package/shared/room-typing.d.mts +16 -0
  33. package/shared/room-typing.mjs +112 -0
  34. package/shared/room-unread-client.ts +128 -0
  35. package/shared/room-unread.d.mts +25 -0
  36. package/shared/room-unread.mjs +57 -0
  37. package/shared/room-unread.test.mjs +91 -0
  38. package/shared/rooms/RoomsDirectory.vue +1 -1
  39. package/shared/rooms/directory.ts +1 -0
  40. package/shared/slash-commands.d.mts +31 -0
  41. package/shared/slash-commands.mjs +48 -0
  42. package/shared/slash-commands.test.mjs +26 -0
  43. package/shared/ui/MessageReactionBar.vue +65 -0
  44. package/shared/ui/MessageReactionPicker.vue +291 -0
  45. package/shared/ui/MessageSearchResults.vue +106 -0
  46. package/shared/ui/PinnedMessages.vue +124 -0
  47. package/shared/ui/TypingIndicator.vue +22 -0
  48. package/shared/ui/message-reactions.css +254 -0
  49. package/shared/ui/message-search.css +186 -0
  50. package/shared/ui/useComposerSlashCommands.ts +92 -0
package/README.md CHANGED
@@ -212,8 +212,6 @@ The API runs at `http://localhost:3001`. Point `LETAGENTS_API_URL` at your serve
212
212
 
213
213
  Optional — **long room long-polls** (multi-hour `wait_for_messages` / `GET …/messages/poll`): set the **same** `LETAGENTS_POLL_MAX_MS` on **both** the API process and any MCP client you run from source (milliseconds; default `180000`).
214
214
 
215
- Optional — **offline-with-work notice grace**: set `LETAGENTS_LIVENESS_NOTICE_AFTER_MS` on the API process (milliseconds; default `1800000`, or 30 minutes). The room is told about an unreachable worker only while it holds an active work lease, and only after this grace; ordinary connects and disconnects are never posted (the roster shows presence). Internal transport staleness remains 2 minutes for routing and diagnostics.
216
-
217
215
  The API now uses PostgreSQL with Drizzle ORM. `DB_URL` must be set before starting the server or running migrations.
218
216
 
219
217
  Useful database commands:
@@ -122,11 +122,28 @@ function withThreadMetadata(record, recordsById, summaries) {
122
122
  },
123
123
  };
124
124
  }
125
+ /**
126
+ * Who reacted is worth an agent's attention; an empty list on every message,
127
+ * or an avatar address per reactor, is not.
128
+ */
129
+ function withAgentReadableReactions(record) {
130
+ const { reactions, ...rest } = record;
131
+ if (!Array.isArray(reactions))
132
+ return record;
133
+ const readable = reactions.filter(isRecord).map((reaction) => ({
134
+ emoji: reaction.emoji,
135
+ count: reaction.count,
136
+ by: (Array.isArray(reaction.reactors) ? reaction.reactors : [])
137
+ .filter(isRecord)
138
+ .map((reactor) => reactor.name ?? reactor.login),
139
+ }));
140
+ return readable.length ? { ...rest, reactions: readable } : rest;
141
+ }
125
142
  function toAgentReadableMessage(message, recordsById, summaries) {
126
143
  if (!message || typeof message !== "object") {
127
144
  return message;
128
145
  }
129
- const record = withThreadMetadata(message, recordsById, summaries);
146
+ const record = withThreadMetadata(withAgentReadableReactions(message), recordsById, summaries);
130
147
  const kind = normalizeAgentPromptKind(record.agent_prompt_kind);
131
148
  const text = typeof record.text === "string" ? record.text : null;
132
149
  if (!kind || text === null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letagents",
3
- "version": "0.12.34",
3
+ "version": "0.12.35",
4
4
  "description": "Let Agents Chat \u2014 MCP server for AI agent communication",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/server.js",
@@ -0,0 +1,193 @@
1
+ /* ─── Shared Fenced Code Block & Syntax Highlighting Styles ─── */
2
+
3
+ .fenced-code-block {
4
+ position: relative;
5
+ max-width: 100%;
6
+ margin: 0.75em 0;
7
+ padding: 0;
8
+ overflow: hidden;
9
+ border: 1px solid var(--border);
10
+ border-radius: var(--radius-md, 8px);
11
+ background: var(--code-bg, color-mix(in srgb, var(--bg) 88%, transparent));
12
+ color: var(--code-text, var(--text));
13
+ font-family: var(--font-mono);
14
+ font-size: 0.82rem;
15
+ line-height: 1.55;
16
+ }
17
+
18
+ .fenced-code-block-header {
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: space-between;
22
+ padding: 6px 12px;
23
+ background: var(--code-header-bg, color-mix(in srgb, var(--text) 4%, transparent));
24
+ border-bottom: 1px solid var(--border);
25
+ user-select: none;
26
+ font-family: var(--font-sans);
27
+ font-size: 0.75rem;
28
+ line-height: 1.2;
29
+ }
30
+
31
+ .fenced-code-block-lang {
32
+ color: var(--code-lang, var(--text-secondary));
33
+ font-weight: 500;
34
+ text-transform: lowercase;
35
+ }
36
+
37
+ .fenced-code-block-lang[data-lang]::before {
38
+ content: attr(data-lang);
39
+ }
40
+
41
+ .fenced-code-block-copy {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ justify-content: center;
45
+ margin-left: auto;
46
+ padding: 3px 8px;
47
+ border: 1px solid var(--border);
48
+ border-radius: var(--radius-sm, 4px);
49
+ background: var(--code-copy-bg, transparent);
50
+ color: var(--code-copy-text, var(--text-secondary));
51
+ font-family: var(--font-sans);
52
+ font-size: 0.72rem;
53
+ font-weight: 500;
54
+ line-height: 1.2;
55
+ cursor: pointer;
56
+ transition: color var(--duration-fast, 150ms) var(--ease-out, ease),
57
+ background var(--duration-fast, 150ms) var(--ease-out, ease),
58
+ border-color var(--duration-fast, 150ms) var(--ease-out, ease);
59
+ }
60
+
61
+ @media (hover: hover) and (pointer: fine) {
62
+ .fenced-code-block-copy:hover {
63
+ background: var(--accent-hover, rgba(255, 255, 255, 0.08));
64
+ color: var(--text);
65
+ border-color: var(--border-strong);
66
+ }
67
+ }
68
+
69
+ .fenced-code-block-copy:focus-visible {
70
+ outline: 2px solid var(--blue, #3b82f6);
71
+ outline-offset: 1px;
72
+ }
73
+
74
+ .fenced-code-block-copy::before {
75
+ content: "Copy";
76
+ }
77
+
78
+ .fenced-code-block-copy[data-copied="true"] {
79
+ color: var(--green-text, #4ade80);
80
+ border-color: color-mix(in srgb, var(--green-text, #4ade80) 40%, transparent);
81
+ }
82
+
83
+ .fenced-code-block-copy[data-copied="true"]::before {
84
+ content: "Copied";
85
+ }
86
+
87
+ .fenced-code-block-copy[data-copied="error"] {
88
+ color: var(--red-text, #f87171);
89
+ border-color: color-mix(in srgb, var(--red-text, #f87171) 40%, transparent);
90
+ }
91
+
92
+ .fenced-code-block-copy[data-copied="error"]::before {
93
+ content: "Failed";
94
+ }
95
+
96
+ /*
97
+ * Eleven different surfaces across desktop and web (e.g. chat messages, long message reader,
98
+ * board task inspector, thread panel, events view, markdown editor preview, agent inspector)
99
+ * define conflicting styles for `pre` with varying selector specificity.
100
+ * The `!important` declarations below ensure the fenced code block pre maintains a clean,
101
+ * borderless, transparent container with standard monospace sizing across all surfaces.
102
+ */
103
+ .fenced-code-block pre {
104
+ margin: 0 !important;
105
+ padding: 10px 14px !important;
106
+ overflow-x: auto !important;
107
+ border: none !important;
108
+ border-radius: 0 !important;
109
+ background: transparent !important;
110
+ line-height: 1.55;
111
+ font-family: var(--font-mono);
112
+ font-size: 0.82rem;
113
+ white-space: pre;
114
+ }
115
+
116
+ .fenced-code-block code.fenced-code-block-content {
117
+ display: block;
118
+ padding: 0;
119
+ overflow-x: visible;
120
+ background: transparent;
121
+ color: inherit;
122
+ font-family: inherit;
123
+ font-size: inherit;
124
+ line-height: inherit;
125
+ white-space: pre;
126
+ }
127
+
128
+ /* ─── Syntax Highlighting Token Mappings ─── */
129
+ .hljs-comment,
130
+ .hljs-quote {
131
+ color: var(--code-comment, var(--text-secondary));
132
+ font-style: italic;
133
+ }
134
+
135
+ .hljs-keyword,
136
+ .hljs-selector-tag,
137
+ .hljs-subst {
138
+ color: var(--code-keyword, var(--task-assigned));
139
+ }
140
+
141
+ .hljs-string,
142
+ .hljs-doctag,
143
+ .hljs-regexp {
144
+ color: var(--code-string, var(--green-text));
145
+ }
146
+
147
+ .hljs-number,
148
+ .hljs-literal {
149
+ color: var(--code-number, var(--amber-text));
150
+ }
151
+
152
+ .hljs-title,
153
+ .hljs-title.function_,
154
+ .hljs-title.class_,
155
+ .hljs-section {
156
+ color: var(--code-function, var(--blue-text));
157
+ }
158
+
159
+ .hljs-variable,
160
+ .hljs-template-variable,
161
+ .hljs-attr,
162
+ .hljs-attribute {
163
+ color: var(--code-variable, var(--task-in-review));
164
+ }
165
+
166
+ .hljs-type,
167
+ .hljs-built_in,
168
+ .hljs-name {
169
+ color: var(--code-type, var(--red-text));
170
+ }
171
+
172
+ .hljs-meta,
173
+ .hljs-tag {
174
+ color: var(--code-comment, var(--text-secondary));
175
+ }
176
+
177
+ .hljs-addition {
178
+ color: var(--code-string, var(--green-text));
179
+ background-color: var(--green-dim, rgba(34, 197, 94, 0.12));
180
+ }
181
+
182
+ .hljs-deletion {
183
+ color: var(--code-type, var(--red-text));
184
+ background-color: var(--red-dim, rgba(239, 68, 68, 0.12));
185
+ }
186
+
187
+ .hljs-emphasis {
188
+ font-style: italic;
189
+ }
190
+
191
+ .hljs-strong {
192
+ font-weight: bold;
193
+ }
@@ -0,0 +1,109 @@
1
+ export type CanonicalLanguage =
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
+ export type LanguageAlias =
26
+ | 'js'
27
+ | 'jsx'
28
+ | 'mjs'
29
+ | 'cjs'
30
+ | 'ts'
31
+ | 'tsx'
32
+ | 'mts'
33
+ | 'cts'
34
+ | 'py'
35
+ | 'sh'
36
+ | 'zsh'
37
+ | 'shell'
38
+ | 'yml'
39
+ | 'html'
40
+ | 'xhtml'
41
+ | 'svg'
42
+ | 'rs'
43
+ | 'golang'
44
+ | 'c++'
45
+ | 'cc'
46
+ | 'hpp'
47
+ | 'h'
48
+ | 'cs'
49
+ | 'docker'
50
+ | 'rb'
51
+ | 'kt'
52
+ | 'kts'
53
+ | 'toml';
54
+
55
+ export type CodeHighlightLanguage = CanonicalLanguage;
56
+
57
+ export interface ResolvedLanguage {
58
+ canonical: CanonicalLanguage;
59
+ label: string;
60
+ }
61
+
62
+ export interface CodeHighlighterOptions {
63
+ highlight: (code: string, canonicalLanguage: CanonicalLanguage) => string;
64
+ now?: () => number;
65
+ maxBudgetMs?: number;
66
+ windowMs?: number;
67
+ }
68
+
69
+ export interface CodeHighlighter {
70
+ renderCodeBlock(code: string, rawTag?: string): string;
71
+ cache: CodeHighlightCache;
72
+ resolveLanguage(rawTag?: string): ResolvedLanguage | null;
73
+ }
74
+
75
+ export const CANONICAL_LANGUAGES: readonly CanonicalLanguage[];
76
+ export const LANGUAGE_ALIASES: ReadonlyMap<string, CanonicalLanguage>;
77
+
78
+ export const MAX_HIGHLIGHT_CHARS: number;
79
+ export const MAX_HIGHLIGHT_LINES: number;
80
+ export const MAX_HIGHLIGHT_LINE_LENGTH: number;
81
+ export const MAX_CACHE_ENTRIES: number;
82
+ export const MAX_CACHE_CHARS: number;
83
+
84
+ export const DEFAULT_TIME_BUDGET_MS: number;
85
+ export const DEFAULT_TIME_WINDOW_MS: number;
86
+
87
+ export function escapeHtml(value: unknown): string;
88
+ export function escapeAttribute(value: unknown): string;
89
+ export function resolveLanguage(rawTag?: string): ResolvedLanguage | null;
90
+
91
+ export class CodeHighlightCache {
92
+ constructor(maxEntries?: number, maxChars?: number);
93
+ get(key: string): string | undefined;
94
+ set(key: string, value: string): void;
95
+ clear(): void;
96
+ readonly size: number;
97
+ readonly totalChars: number;
98
+ }
99
+
100
+ export function createCodeHighlighter(options: CodeHighlighterOptions): CodeHighlighter;
101
+ export function executeCodeBlockCopy(
102
+ button: HTMLButtonElement,
103
+ textToCopy: string | null,
104
+ copyFn: (text: string) => Promise<boolean> | boolean,
105
+ copyTimers?: WeakMap<HTMLButtonElement, number>
106
+ ): Promise<{ success: boolean; timerId: number | null }>;
107
+ export function setupCodeBlockCopyListener(
108
+ copyFn: (text: string) => Promise<boolean> | boolean
109
+ ): () => void;
@@ -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, '&amp;')
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
+ }
@@ -188,7 +188,7 @@ export function assertProjectKeepsLetAgentsServer(inProject, outsideProject) {
188
188
  /**
189
189
  * Every override a LetAgents launch adds: the features, each personal skill,
190
190
  * 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
191
+ * `configOverrides` are the launch's own (for example the room's server), used to
192
192
  * list servers as the launch will see them. A launch that cannot list its
193
193
  * servers, or whose project changes the LetAgents server, fails here.
194
194
  */