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,130 @@
1
+ // Emoji reactions on room messages: the limits, the quick palette and the one
2
+ // validation rule that the server and both clients share.
3
+
4
+ export const MESSAGE_REACTION_EMOJI_MAX_BYTES = 64;
5
+ export const MESSAGE_REACTION_MAX_DISTINCT_PER_MESSAGE = 20;
6
+ // A reaction always reports its exact count; only the named reactors are capped.
7
+ export const MESSAGE_REACTION_MAX_REACTORS_LISTED = 50;
8
+ // One range read covers at most this many message numbers, and returns at most
9
+ // this many reactor rows before it stops and names where to continue.
10
+ export const MESSAGE_REACTION_RANGE_MAX_SPAN = 1000;
11
+ export const MESSAGE_REACTION_MAX_PER_READ = 2000;
12
+
13
+ export const MESSAGE_REACTION_QUICK_EMOJI = Object.freeze([
14
+ "👍", "👎", "✅", "❌", "👀", "🙏", "🎉", "🚀",
15
+ "❤️", "😂", "🤔", "😅", "🔥", "💯", "👏", "🙌",
16
+ "🤝", "⚠️", "🐛", "🔧", "📌", "⏳", "💡", "❓",
17
+ ]);
18
+
19
+ const VARIATION_SELECTOR_16 = "️";
20
+ const encoder = new TextEncoder();
21
+
22
+ // `\p{RGI_Emoji}` needs the `v` flag, which every runtime we ship has. The
23
+ // fallback keeps an older browser usable; the server stays the authority.
24
+ const emojiPattern = (() => {
25
+ try {
26
+ return new RegExp("^\\p{RGI_Emoji}$", "v");
27
+ } catch {
28
+ return /^(?:\p{Regional_Indicator}{2}|[#*0-9]️?⃣|\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?(?:‍\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?)*)$/u;
29
+ }
30
+ })();
31
+
32
+ /**
33
+ * Return the canonical form of one emoji, or null when the value is anything
34
+ * else (text, several emoji, an oversized sequence).
35
+ */
36
+ export function normalizeMessageReactionEmoji(value) {
37
+ if (typeof value !== "string") return null;
38
+ const trimmed = value.trim();
39
+ if (!trimmed || encoder.encode(trimmed).length > MESSAGE_REACTION_EMOJI_MAX_BYTES) return null;
40
+ if (emojiPattern.test(trimmed)) return trimmed;
41
+ // A bare text-style symbol such as "❤" is the same reaction as "❤️".
42
+ const qualified = `${trimmed}${VARIATION_SELECTOR_16}`;
43
+ return emojiPattern.test(qualified) ? qualified : null;
44
+ }
45
+
46
+ function normalizeReactor(value) {
47
+ if (!value || typeof value !== "object") return null;
48
+ const login = typeof value.login === "string" ? value.login.trim() : "";
49
+ if (!login || login.length > 256) return null;
50
+ const name = typeof value.name === "string" && value.name.trim() ? value.name.trim().slice(0, 256) : login;
51
+ const avatarUrl = typeof value.avatar_url === "string" && /^https:\/\//.test(value.avatar_url)
52
+ ? value.avatar_url.slice(0, 2048)
53
+ : null;
54
+ return { login, name, avatar_url: avatarUrl };
55
+ }
56
+
57
+ /**
58
+ * Read a server reaction list defensively. Anything malformed is dropped, so a
59
+ * client never renders a reaction it could not also send.
60
+ */
61
+ export function normalizeMessageReactions(value) {
62
+ if (!Array.isArray(value)) return [];
63
+ const reactions = [];
64
+ const seen = new Set();
65
+ for (const item of value) {
66
+ if (reactions.length >= MESSAGE_REACTION_MAX_DISTINCT_PER_MESSAGE) break;
67
+ if (!item || typeof item !== "object") continue;
68
+ const emoji = normalizeMessageReactionEmoji(item.emoji);
69
+ if (!emoji || seen.has(emoji)) continue;
70
+ const reactors = (Array.isArray(item.reactors) ? item.reactors : [])
71
+ .slice(0, MESSAGE_REACTION_MAX_REACTORS_LISTED)
72
+ .map(normalizeReactor)
73
+ .filter(Boolean);
74
+ const count = Number.isSafeInteger(item.count) && item.count > 0 ? item.count : reactors.length;
75
+ if (count <= 0) continue;
76
+ seen.add(emoji);
77
+ reactions.push({ emoji, count: Math.max(count, reactors.length), reactors });
78
+ }
79
+ return reactions;
80
+ }
81
+
82
+ function sameLogin(left, right) {
83
+ return typeof left === "string" && typeof right === "string"
84
+ && left.length > 0 && left.toLowerCase() === right.toLowerCase();
85
+ }
86
+
87
+ /** Whether the listed reactors include the viewer. */
88
+ export function viewerReactedWith(reaction, viewerLogin) {
89
+ return Boolean(reaction?.reactors?.some((reactor) => sameLogin(reactor.login, viewerLogin)));
90
+ }
91
+
92
+ /** "You, Ada and 3 others reacted with 👍" — the tooltip and the accessible name. */
93
+ export function describeMessageReaction(reaction, viewerLogin) {
94
+ const reactors = Array.isArray(reaction?.reactors) ? reaction.reactors : [];
95
+ const viewerReacted = viewerReactedWith(reaction, viewerLogin);
96
+ const names = [
97
+ ...(viewerReacted ? ["You"] : []),
98
+ ...reactors.filter((reactor) => !sameLogin(reactor.login, viewerLogin)).map((reactor) => reactor.name),
99
+ ];
100
+ const count = Math.max(Number(reaction?.count) || 0, names.length);
101
+ const shown = names.slice(0, 3);
102
+ const others = count - shown.length;
103
+ if (others > 0) shown.push(others === 1 ? "1 other" : `${others} others`);
104
+ const who = shown.length <= 1
105
+ ? shown[0] ?? "Someone"
106
+ : `${shown.slice(0, -1).join(", ")} and ${shown.at(-1)}`;
107
+ return `${who} reacted with ${reaction?.emoji ?? ""}`.trim();
108
+ }
109
+
110
+ /**
111
+ * The reaction list after the viewer toggles one emoji, for an optimistic
112
+ * update. The server's answer replaces it either way.
113
+ */
114
+ export function toggleViewerMessageReaction(reactions, emoji, viewer) {
115
+ const current = Array.isArray(reactions) ? reactions : [];
116
+ const existing = current.find((reaction) => reaction.emoji === emoji);
117
+ if (!existing) {
118
+ return [...current, { emoji, count: 1, reactors: [{ ...viewer }] }];
119
+ }
120
+ if (viewerReactedWith(existing, viewer.login)) {
121
+ const reactors = existing.reactors.filter((reactor) => !sameLogin(reactor.login, viewer.login));
122
+ const count = existing.count - 1;
123
+ return count <= 0
124
+ ? current.filter((reaction) => reaction !== existing)
125
+ : current.map((reaction) => (reaction === existing ? { ...existing, count, reactors } : reaction));
126
+ }
127
+ return current.map((reaction) => (reaction === existing
128
+ ? { ...existing, count: existing.count + 1, reactors: [...existing.reactors, { ...viewer }] }
129
+ : reaction));
130
+ }
@@ -0,0 +1,38 @@
1
+ import type { MessageSearchQueryError, MessageSearchResponse } from "./message-search.mjs";
2
+
3
+ export const MESSAGE_SEARCH_DEBOUNCE_MS: 250;
4
+
5
+ export interface MessageSearchState<Hit extends { id: string }> {
6
+ /**
7
+ * `idle`: nothing to search yet. `invalid`: the query breaks a limit (see `error`).
8
+ * `loading`: the first page is on its way. `ready` / `error`: it arrived, or failed.
9
+ */
10
+ status: "idle" | "invalid" | "loading" | "ready" | "error";
11
+ terms: readonly string[];
12
+ /** Newest first. */
13
+ hits: readonly Hit[];
14
+ hasMore: boolean;
15
+ loadingMore: boolean;
16
+ /** A query limit name when `invalid`; otherwise the last request's failure, if any. */
17
+ error: MessageSearchQueryError | string | null;
18
+ }
19
+
20
+ export interface MessageSearchController<Hit extends { id: string }> {
21
+ readonly state: MessageSearchState<Hit>;
22
+ /** Search for a new query after a short pause in typing. */
23
+ setQuery(value: string): void;
24
+ /** Read the next, older page of the current results. */
25
+ loadMore(): Promise<void>;
26
+ /** Stop searching; call when the search closes or the room changes. */
27
+ reset(): void;
28
+ }
29
+
30
+ /**
31
+ * The client-side state of one room's history search, shared by the desktop
32
+ * and web apps. Answers that arrive for an earlier query are dropped.
33
+ */
34
+ export function createMessageSearchController<Hit extends { id: string }>(options: {
35
+ search(query: string, before: string | null): Promise<Pick<MessageSearchResponse<Hit>, "messages" | "has_more" | "next_before"> & { terms?: string[] }>;
36
+ onChange?(): void;
37
+ debounceMs?: number;
38
+ }): MessageSearchController<Hit>;
@@ -0,0 +1,99 @@
1
+ // The client-side state of one room's history search, shared by the desktop
2
+ // and web apps. Framework-free: the app supplies the request and is told when
3
+ // something changed.
4
+ import { parseMessageSearchQuery } from "./message-search.mjs";
5
+
6
+ export const MESSAGE_SEARCH_DEBOUNCE_MS = 250;
7
+
8
+ const IDLE = Object.freeze({ status: "idle", terms: [], hits: [], hasMore: false, loadingMore: false, error: null });
9
+
10
+ export function createMessageSearchController({ search, onChange, debounceMs = MESSAGE_SEARCH_DEBOUNCE_MS }) {
11
+ let state = IDLE;
12
+ let query = "";
13
+ let nextBefore = null;
14
+ // Every new query (and reset) starts an epoch; answers from an earlier one are dropped.
15
+ let epoch = 0;
16
+ let timer = null;
17
+
18
+ function set(next) {
19
+ state = next;
20
+ onChange?.();
21
+ }
22
+
23
+ function cancelTimer() {
24
+ if (timer !== null) clearTimeout(timer);
25
+ timer = null;
26
+ }
27
+
28
+ async function run(terms, startedIn) {
29
+ try {
30
+ const page = await search(query, null);
31
+ if (startedIn !== epoch) return;
32
+ nextBefore = page?.next_before ?? null;
33
+ set({
34
+ status: "ready",
35
+ terms: Array.isArray(page?.terms) ? page.terms : terms,
36
+ hits: Array.isArray(page?.messages) ? page.messages : [],
37
+ hasMore: Boolean(page?.has_more && nextBefore),
38
+ loadingMore: false,
39
+ error: null,
40
+ });
41
+ } catch (error) {
42
+ if (startedIn !== epoch) return;
43
+ set({ ...IDLE, status: "error", terms, error: error instanceof Error && error.message ? error.message : "Search failed." });
44
+ }
45
+ }
46
+
47
+ /** Search for a new query after a short pause in typing. */
48
+ function setQuery(value) {
49
+ const next = typeof value === "string" ? value : "";
50
+ if (next.trim() === query.trim() && state !== IDLE) return;
51
+ query = next;
52
+ epoch += 1;
53
+ cancelTimer();
54
+ nextBefore = null;
55
+ const parsed = parseMessageSearchQuery(query);
56
+ if (parsed.error) {
57
+ // Too short is simply "not searching yet"; the other limits are worth saying.
58
+ set(parsed.error === "too_short" ? IDLE : { ...IDLE, status: "invalid", error: parsed.error });
59
+ return;
60
+ }
61
+ const startedIn = epoch;
62
+ set({ ...IDLE, status: "loading", terms: parsed.terms });
63
+ timer = setTimeout(() => {
64
+ timer = null;
65
+ void run(parsed.terms, startedIn);
66
+ }, debounceMs);
67
+ }
68
+
69
+ /** Read the next, older page of the current results. */
70
+ async function loadMore() {
71
+ if (state.status !== "ready" || !state.hasMore || state.loadingMore || !nextBefore) return;
72
+ const startedIn = epoch;
73
+ const before = nextBefore;
74
+ set({ ...state, loadingMore: true, error: null });
75
+ try {
76
+ const page = await search(query, before);
77
+ if (startedIn !== epoch) return;
78
+ nextBefore = page?.next_before ?? null;
79
+ const known = new Set(state.hits.map((hit) => hit.id));
80
+ const more = (Array.isArray(page?.messages) ? page.messages : []).filter((hit) => !known.has(hit.id));
81
+ set({ ...state, hits: [...state.hits, ...more], hasMore: Boolean(page?.has_more && nextBefore), loadingMore: false });
82
+ } catch (error) {
83
+ if (startedIn !== epoch) return;
84
+ // Keep what is shown; the person can press "more" again.
85
+ set({ ...state, loadingMore: false, error: error instanceof Error && error.message ? error.message : "Search failed." });
86
+ }
87
+ }
88
+
89
+ /** Stop searching; call when the search closes or the room changes. */
90
+ function reset() {
91
+ query = "";
92
+ epoch += 1;
93
+ cancelTimer();
94
+ nextBefore = null;
95
+ if (state !== IDLE) set(IDLE);
96
+ }
97
+
98
+ return { get state() { return state; }, setQuery, loadMore, reset };
99
+ }
@@ -0,0 +1,46 @@
1
+ export const MESSAGE_SEARCH_MIN_QUERY_CHARS: 2;
2
+ export const MESSAGE_SEARCH_MAX_QUERY_CHARS: 200;
3
+ export const MESSAGE_SEARCH_MAX_TERMS: 6;
4
+ export const MESSAGE_SEARCH_DEFAULT_LIMIT: 30;
5
+ export const MESSAGE_SEARCH_MAX_LIMIT: 50;
6
+
7
+ export type MessageSearchQueryError = "too_short" | "too_long" | "too_many_terms";
8
+
9
+ export type ParsedMessageSearchQuery =
10
+ | { terms: string[]; error?: undefined }
11
+ | { error: MessageSearchQueryError; terms?: undefined };
12
+
13
+ /**
14
+ * Split a query into its terms, or say why it cannot be searched. A term is a
15
+ * word, or a phrase in double quotes; a message matches when it contains every
16
+ * term as a substring, ignoring case.
17
+ */
18
+ export function parseMessageSearchQuery(value: unknown): ParsedMessageSearchQuery;
19
+
20
+ /** Whether a text contains every term, the same test the server applies. */
21
+ export function textMatchesMessageSearch(text: unknown, terms: readonly string[]): boolean;
22
+
23
+ /** `GET /rooms/:room/messages/search` */
24
+ export interface MessageSearchResponse<Message = unknown> {
25
+ room_id: string;
26
+ terms: string[];
27
+ /** Newest first. */
28
+ messages: Message[];
29
+ has_more: boolean;
30
+ /** Pass as `before` to read the next, older page; null on the last page. */
31
+ next_before: string | null;
32
+ }
33
+
34
+ export interface MessageSearchSegment {
35
+ text: string;
36
+ match: boolean;
37
+ }
38
+
39
+ /**
40
+ * Split a text into runs that do and do not match the terms, for rendering
41
+ * highlights as plain text nodes (never as HTML).
42
+ */
43
+ export function highlightMessageSearchText(text: unknown, terms: readonly string[]): MessageSearchSegment[];
44
+
45
+ /** A short excerpt around the first match, with an ellipsis where text was left out. */
46
+ export function messageSearchSnippet(text: unknown, terms: readonly string[], maxLength?: number): string;
@@ -0,0 +1,114 @@
1
+ // Searching a room's messages: the limits and the one query grammar that the
2
+ // server and both apps share. A query is a list of terms; a message matches
3
+ // when it contains every term, ignoring case. A term is a word, or a phrase
4
+ // in double quotes. Matching is by substring, so part of an identifier, a
5
+ // file path or an error string finds the message.
6
+
7
+ export const MESSAGE_SEARCH_MIN_QUERY_CHARS = 2;
8
+ export const MESSAGE_SEARCH_MAX_QUERY_CHARS = 200;
9
+ export const MESSAGE_SEARCH_MAX_TERMS = 6;
10
+ export const MESSAGE_SEARCH_DEFAULT_LIMIT = 30;
11
+ export const MESSAGE_SEARCH_MAX_LIMIT = 50;
12
+
13
+ /**
14
+ * Split a query into its terms, or say why it cannot be searched.
15
+ * Returns `{ terms }` or `{ error: "too_short" | "too_long" | "too_many_terms" }`.
16
+ */
17
+ export function parseMessageSearchQuery(value) {
18
+ const query = typeof value === "string" ? value.trim() : "";
19
+ if (query.length > MESSAGE_SEARCH_MAX_QUERY_CHARS) return { error: "too_long" };
20
+
21
+ const terms = [];
22
+ const seen = new Set();
23
+ // A phrase runs to its closing quote, or to the end when the quote is never closed.
24
+ for (const match of query.matchAll(/"([^"]*)"?|(\S+)/g)) {
25
+ const term = (match[1] ?? match[2] ?? "").replace(/\s+/g, " ").trim();
26
+ const key = term.toLowerCase();
27
+ if (!term || seen.has(key)) continue;
28
+ seen.add(key);
29
+ terms.push(term);
30
+ }
31
+ if (terms.join("").length < MESSAGE_SEARCH_MIN_QUERY_CHARS) return { error: "too_short" };
32
+ if (terms.length > MESSAGE_SEARCH_MAX_TERMS) return { error: "too_many_terms" };
33
+ return { terms };
34
+ }
35
+
36
+ /** Whether a text contains every term, the same test the server applies. */
37
+ export function textMatchesMessageSearch(text, terms) {
38
+ const haystack = String(text ?? "").toLowerCase();
39
+ return terms.length > 0 && terms.every((term) => haystack.includes(term.toLowerCase()));
40
+ }
41
+
42
+ function matchRanges(text, terms) {
43
+ const lower = text.toLowerCase();
44
+ // Lowercasing can expand a character (İ -> i + combining dot). Keep the
45
+ // original UTF-16 boundaries so highlights and snippets still slice source text.
46
+ let sourceOffsets = null;
47
+ if (lower.length !== text.length) {
48
+ sourceOffsets = [];
49
+ let start = 0;
50
+ for (const character of text) {
51
+ const end = start + character.length;
52
+ for (let index = 0; index < character.toLowerCase().length; index += 1) {
53
+ sourceOffsets.push([start, end]);
54
+ }
55
+ start = end;
56
+ }
57
+ // Context-sensitive casing must not leave offsets outside the map.
58
+ if (sourceOffsets.length !== lower.length) return [];
59
+ }
60
+ const ranges = [];
61
+ for (const term of terms) {
62
+ const needle = term.toLowerCase();
63
+ if (!needle) continue;
64
+ for (let at = lower.indexOf(needle); at !== -1; at = lower.indexOf(needle, at + 1)) {
65
+ ranges.push(sourceOffsets
66
+ ? [sourceOffsets[at][0], sourceOffsets[at + needle.length - 1][1]]
67
+ : [at, at + needle.length]);
68
+ }
69
+ }
70
+ ranges.sort((left, right) => left[0] - right[0] || left[1] - right[1]);
71
+ // Overlapping and touching matches read as one highlighted run.
72
+ const merged = [];
73
+ for (const range of ranges) {
74
+ const last = merged.at(-1);
75
+ if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
76
+ else merged.push([...range]);
77
+ }
78
+ return merged;
79
+ }
80
+
81
+ /**
82
+ * Split a text into runs that do and do not match the terms, for rendering
83
+ * highlights as plain text nodes (never as HTML).
84
+ */
85
+ export function highlightMessageSearchText(text, terms) {
86
+ const source = String(text ?? "");
87
+ const segments = [];
88
+ let cursor = 0;
89
+ for (const [start, end] of matchRanges(source, terms)) {
90
+ if (start > cursor) segments.push({ text: source.slice(cursor, start), match: false });
91
+ segments.push({ text: source.slice(start, end), match: true });
92
+ cursor = end;
93
+ }
94
+ if (cursor < source.length) segments.push({ text: source.slice(cursor), match: false });
95
+ return segments;
96
+ }
97
+
98
+ /**
99
+ * A short excerpt around the first match: whitespace collapsed, cut at
100
+ * `maxLength` characters, with an ellipsis where text was left out.
101
+ */
102
+ export function messageSearchSnippet(text, terms, maxLength = 220) {
103
+ const source = String(text ?? "").replace(/\s+/g, " ").trim();
104
+ if (source.length <= maxLength) return source;
105
+ const first = matchRanges(source, terms)[0]?.[0] ?? 0;
106
+ // Keep a little lead-in so the match is read in its sentence.
107
+ let start = Math.max(0, Math.min(first - Math.floor(maxLength / 4), source.length - maxLength));
108
+ if (start > 0) {
109
+ const space = source.indexOf(" ", start);
110
+ if (space !== -1 && space < first) start = space + 1;
111
+ }
112
+ const end = Math.min(source.length, start + maxLength);
113
+ return `${start > 0 ? "…" : ""}${source.slice(start, end).trim()}${end < source.length ? "…" : ""}`;
114
+ }
@@ -0,0 +1,28 @@
1
+ export class PacedQueueClosedError extends Error {
2
+ constructor(message?: string);
3
+ }
4
+
5
+ export type PacedQueueOptions = {
6
+ capacity: number;
7
+ /** Slots only rank-0 (urgent) work may use. */
8
+ reservedForUrgent?: number;
9
+ leaseMs: number;
10
+ startJitterMs?: number;
11
+ random?: () => number;
12
+ setTimeout?: typeof setTimeout;
13
+ clearTimeout?: typeof clearTimeout;
14
+ };
15
+
16
+ export type PacedQueueAdmission = {
17
+ rank?: () => number | Promise<number>;
18
+ pauseWhenQueued?: () => boolean;
19
+ signal?: AbortSignal;
20
+ };
21
+
22
+ export class PacedQueue {
23
+ constructor(options: PacedQueueOptions);
24
+ acquire(key: string, admission?: PacedQueueAdmission): Promise<() => void>;
25
+ reprioritize(key: string, rank: number): void;
26
+ close(createError?: () => Error): void;
27
+ snapshot(): { active: number; queued: string[] };
28
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * A bounded, priority-ordered admission queue for work that loads a shared
3
+ * server or the machine. It exists so that "everything at once" (a desktop
4
+ * start or a daemon handoff converging every agent together) becomes a few
5
+ * at a time, most urgent first.
6
+ *
7
+ * Queue time is never part of the queued work: callers start their own
8
+ * request timeouts and startup budgets after a slot is granted. Callers must
9
+ * pace only leaf work that never waits for another slot of the same queue,
10
+ * so a queue cannot deadlock on itself.
11
+ */
12
+
13
+ export class PacedQueueClosedError extends Error {
14
+ constructor(message = "Queued work was cancelled because its owner is shutting down.") {
15
+ super(message);
16
+ this.name = "PacedQueueClosedError";
17
+ }
18
+ }
19
+
20
+ export class PacedQueue {
21
+ #capacity;
22
+ #reservedForUrgent;
23
+ #leaseMs;
24
+ #startJitterMs;
25
+ #random;
26
+ #setTimeout;
27
+ #clearTimeout;
28
+ #active = 0;
29
+ #queue = [];
30
+ #sequence = 0;
31
+ #closedError = null;
32
+
33
+ /**
34
+ * Rank 0 is urgent work (a user's action). `reservedForUrgent` of the
35
+ * `capacity` slots serve only it, so a queue full of background work can
36
+ * never make a user wait for one of that work's slots to free up.
37
+ */
38
+ constructor({ capacity, reservedForUrgent = 0, leaseMs, startJitterMs = 0, random = Math.random, setTimeout: setTimer = setTimeout, clearTimeout: clearTimer = clearTimeout }) {
39
+ if (!Number.isSafeInteger(capacity) || capacity < 1) throw new Error("A paced queue needs a positive capacity.");
40
+ if (!Number.isSafeInteger(reservedForUrgent) || reservedForUrgent < 0 || reservedForUrgent >= capacity) {
41
+ throw new Error("A paced queue must leave background work at least one slot.");
42
+ }
43
+ if (!Number.isFinite(leaseMs) || leaseMs <= 0) throw new Error("A paced queue needs a positive slot lease.");
44
+ this.#capacity = capacity;
45
+ this.#reservedForUrgent = reservedForUrgent;
46
+ this.#leaseMs = leaseMs;
47
+ this.#startJitterMs = startJitterMs;
48
+ this.#random = random;
49
+ this.#setTimeout = setTimer;
50
+ this.#clearTimeout = clearTimer;
51
+ }
52
+
53
+ /**
54
+ * Wait for a slot and return its idempotent release. `rank` (lower runs
55
+ * first) is read only when the work has to queue. After a queued slot is
56
+ * granted, `pauseWhenQueued` decides whether to wait a random pause of up
57
+ * to the start jitter, so a released batch spreads out. Aborting `signal`
58
+ * while queued removes the work from the queue and rejects.
59
+ */
60
+ async acquire(key, { rank = () => 0, pauseWhenQueued = () => true, signal } = {}) {
61
+ if (this.#closedError) throw this.#closedError();
62
+ signal?.throwIfAborted();
63
+ // Room even for background work and nobody waiting: no rank needed.
64
+ if (this.#queue.length === 0 && this.#active < this.#capacity - this.#reservedForUrgent) {
65
+ this.#active += 1;
66
+ return this.#lease();
67
+ }
68
+ const resolvedRank = await rank();
69
+ if (this.#closedError) throw this.#closedError();
70
+ signal?.throwIfAborted();
71
+ let grant;
72
+ let cancel;
73
+ const admitted = new Promise((resolve, reject) => { grant = resolve; cancel = reject; });
74
+ const waiter = { key, rank: resolvedRank, sequence: this.#sequence++, granted: false, grant, cancel };
75
+ if (signal) {
76
+ const abandon = () => {
77
+ const index = this.#queue.indexOf(waiter);
78
+ if (index < 0) return;
79
+ this.#queue.splice(index, 1);
80
+ cancel(signal.reason);
81
+ };
82
+ signal.addEventListener("abort", abandon, { once: true });
83
+ waiter.grant = () => { signal.removeEventListener("abort", abandon); grant(); };
84
+ waiter.cancel = (error) => { signal.removeEventListener("abort", abandon); cancel(error); };
85
+ }
86
+ this.#queue.push(waiter);
87
+ this.#sort();
88
+ // A slot may have been released while the rank was read, or this may be
89
+ // urgent work entitled to a reserved slot.
90
+ this.#pump();
91
+ const waited = !waiter.granted;
92
+ await admitted;
93
+ const release = this.#lease();
94
+ if (waited && this.#startJitterMs > 0 && pauseWhenQueued()) {
95
+ const pauseMs = Math.floor(this.#random() * this.#startJitterMs);
96
+ if (pauseMs > 0) await new Promise((resolve) => { this.#setTimeout(resolve, pauseMs); });
97
+ }
98
+ return release;
99
+ }
100
+
101
+ /** Move already-queued work for `key` forward to `rank`; never moves it back. */
102
+ reprioritize(key, rank) {
103
+ let changed = false;
104
+ for (const waiter of this.#queue) {
105
+ if (waiter.key === key && waiter.rank > rank) {
106
+ waiter.rank = rank;
107
+ changed = true;
108
+ }
109
+ }
110
+ if (changed) {
111
+ this.#sort();
112
+ this.#pump();
113
+ }
114
+ }
115
+
116
+ /** Cancel queued work and refuse new work; work already admitted runs on. */
117
+ close(createError = () => new PacedQueueClosedError()) {
118
+ this.#closedError = createError;
119
+ for (const waiter of this.#queue.splice(0)) waiter.cancel(createError());
120
+ }
121
+
122
+ snapshot() {
123
+ return { active: this.#active, queued: this.#queue.map((waiter) => waiter.key) };
124
+ }
125
+
126
+ /** Admit queued work from the front while it has a slot it may use. */
127
+ #pump() {
128
+ while (this.#queue.length > 0) {
129
+ const head = this.#queue[0];
130
+ const limit = head.rank === 0 ? this.#capacity : this.#capacity - this.#reservedForUrgent;
131
+ // The queue is ordered by rank, so nothing behind a blocked head may run either.
132
+ if (this.#active >= limit) return;
133
+ this.#queue.shift();
134
+ this.#active += 1;
135
+ head.granted = true;
136
+ head.grant();
137
+ }
138
+ }
139
+
140
+ #sort() {
141
+ this.#queue.sort((left, right) => left.rank - right.rank || left.sequence - right.sequence);
142
+ }
143
+
144
+ #lease() {
145
+ let released = false;
146
+ const release = () => {
147
+ if (released) return;
148
+ released = true;
149
+ this.#clearTimeout(timer);
150
+ this.#active -= 1;
151
+ this.#pump();
152
+ };
153
+ // Every queued operation keeps its own timeout; the lease only stops one
154
+ // that never settles from holding its slot forever.
155
+ const timer = this.#setTimeout(release, this.#leaseMs);
156
+ timer?.unref?.();
157
+ return release;
158
+ }
159
+ }
@@ -19,6 +19,25 @@ export type PermissionReviewRequest = {
19
19
  /** True when a person must decide: no fixed rule can read the command in full. */
20
20
  export function commandNeedsPerson(command: unknown, project: unknown): boolean;
21
21
 
22
+ /** True when each simple command in each part is, word for word, one in the whole command. */
23
+ export function partsAreInCommand(command: unknown, parts: unknown): boolean;
24
+
25
+ /** True when every part only reads project files, history, or this repository's pull requests and issues. */
26
+ export function commandOnlyReads(command: unknown, project: unknown): boolean;
27
+
28
+ export type RoutineCommandContext = {
29
+ /** Whether a branch is the agent's own. */
30
+ ownBranch(branch: string): boolean;
31
+ /** The branch `origin` names as its default; null when it is not known. */
32
+ defaultBranch: string | null;
33
+ };
34
+
35
+ /**
36
+ * Null when a person must decide. Otherwise the parts Jev must still review,
37
+ * each as written: none when the rules alone allow the whole command.
38
+ */
39
+ export function routineCommandReview(command: unknown, project: unknown, context: RoutineCommandContext): string[] | null;
40
+
22
41
  /** Null when the fixed rules already require a person. */
23
42
  export function buildPermissionReviewRequest(input: {
24
43
  commands: readonly string[];