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
@@ -59,27 +59,41 @@ function normalizedProject(project) {
59
59
  && trimmed.split("/").slice(1).every((part) => part && part !== "." && part !== "..") ? trimmed : null;
60
60
  }
61
61
 
62
+ /** The text inside a quote that starts at `index`, and where the quote ends, or null. */
63
+ function quotedText(command, index) {
64
+ const quote = command[index];
65
+ const end = command.indexOf(quote, index + 1);
66
+ if (end < 0) return null;
67
+ const value = command.slice(index + 1, end);
68
+ return (quote === "'" ? SINGLE_QUOTED : DOUBLE_QUOTED).test(value) && !value.includes("\n") ? { value, end: end + 1 } : null;
69
+ }
70
+
62
71
  /**
63
72
  * Split a command into simple commands of words, or return null when any part
64
73
  * of it is written in a way these rules do not read: expansion, substitution,
65
74
  * escaping, grouping, a redirect to a file, a background job, a comment, or a
66
- * quote joined to other text.
75
+ * quote joined to other text other than an option's value.
76
+ *
77
+ * Each simple command also says how it is joined to the one before it, and
78
+ * where it is in the text, so a part of the command can be judged as written.
67
79
  */
68
80
  function readCommands(command) {
69
81
  // Printable ASCII, tab, and new line only. Anything else can look like a space and not be one.
70
82
  if (!/^[\x20-\x7e\t\n]+$/.test(command)) return null;
71
- const commands = [{ words: [], fed: false }];
83
+ const commands = [{ words: [], fed: false, joiner: null, start: -1, end: -1 }];
72
84
  const current = () => commands[commands.length - 1];
73
85
  const boundary = (index) => index >= command.length || " \t\n;|&".includes(command[index]);
86
+ const covers = (from, to) => { if (current().start < 0) current().start = from; current().end = to; };
74
87
  let index = 0;
75
88
  while (index < command.length) {
76
89
  const char = command[index];
77
90
  if (char === " " || char === "\t") { index += 1; continue; }
78
91
  const rest = command.slice(index);
79
92
  const stream = STREAM.exec(rest);
80
- if (stream) { index += stream[0].length; continue; }
93
+ if (stream) { covers(index, index + stream[0].length); index += stream[0].length; continue; }
81
94
  if (char === "\n") {
82
- if (current().words.length > 0) commands.push({ words: [], fed: false });
95
+ if (current().words.length > 0) commands.push({ words: [], fed: false, joiner: "\n", start: -1, end: -1 });
96
+ else current().start = -1;
83
97
  index += 1;
84
98
  continue;
85
99
  }
@@ -88,26 +102,34 @@ function readCommands(command) {
88
102
  // An operator joins two commands. One with nothing before it, or `|&`, `;;`, `&`, is not read.
89
103
  if (current().words.length === 0 || /^[|;&]/.test(rest.slice(operator.length))) return null;
90
104
  // What comes through a pipe is the next program's input, and some programs run their input.
91
- commands.push({ words: [], fed: operator === "|" });
105
+ commands.push({ words: [], fed: operator === "|", joiner: operator, start: -1, end: -1 });
92
106
  index += operator.length;
93
107
  continue;
94
108
  }
95
109
  if (char === "'" || char === '"') {
96
- const end = command.indexOf(char, index + 1);
97
- if (end < 0 || !boundary(end + 1)) return null;
98
- const value = command.slice(index + 1, end);
99
- if (!(char === "'" ? SINGLE_QUOTED : DOUBLE_QUOTED).test(value) || value.includes("\n")) return null;
100
- current().words.push({ value, quoted: true });
101
- index = end + 1;
110
+ const quoted = quotedText(command, index);
111
+ if (!quoted || !boundary(quoted.end)) return null;
112
+ current().words.push({ value: quoted.value, quoted: true });
113
+ covers(index, quoted.end);
114
+ index = quoted.end;
102
115
  continue;
103
116
  }
104
117
  let end = index;
105
118
  while (end < command.length && PLAIN.test(command[end])) end += 1;
106
- if (end === index || !boundary(end)) return null;
107
- const value = command.slice(index, end);
119
+ if (end === index) return null;
120
+ let value = command.slice(index, end);
108
121
  // A shell expands `~` at the start of a word and after `=` or `:`, and zsh expands a leading `=`.
109
122
  if (/(?:^|[=:])~|^=/.test(value)) return null;
123
+ // `--format='%an <%ae>'` is one word to a shell: an option, `=`, and a value quoted whole.
124
+ if (value.endsWith("=") && (command[end] === "'" || command[end] === '"')) {
125
+ const quoted = quotedText(command, end);
126
+ if (!quoted) return null;
127
+ value += quoted.value;
128
+ end = quoted.end;
129
+ }
130
+ if (!boundary(end)) return null;
110
131
  current().words.push({ value, quoted: false });
132
+ covers(index, end);
111
133
  index = end;
112
134
  }
113
135
  // Only `;` or a new line may end a command. `&&`, `||`, and `|` leave the shell waiting for more.
@@ -271,7 +293,8 @@ function nodeAllowed(args, project) {
271
293
  for (; index < args.length && args[index].startsWith("-"); index += 1) {
272
294
  if (args[index] === "--import" && /^tsx(?:\/esm)?$/.test(args[index + 1] ?? "")) index += 1;
273
295
  else if (args[index] === "--test") testing = true;
274
- else if (!/^(?:--import=tsx(?:\/esm)?|--test-concurrency=\d{1,3}|--test-timeout=\d{1,7}|--test-only|--experimental-test-module-mocks|--experimental-strip-types|--no-warnings|--enable-source-maps)$/.test(args[index])) return false;
296
+ // `--check` only parses the script. It never runs it.
297
+ else if (!/^(?:--import=tsx(?:\/esm)?|--test-concurrency=\d{1,3}|--test-timeout=\d{1,7}|--test-only|--experimental-test-module-mocks|--experimental-strip-types|--no-warnings|--enable-source-maps|--check|-c)$/.test(args[index])) return false;
275
298
  }
276
299
  const rest = args.slice(index);
277
300
  // Without a script, node reads its program from the input, unless it was told to find the tests.
@@ -300,9 +323,13 @@ const PROGRAMS = {
300
323
  dirname: reads({}),
301
324
  cut: reads({ valued: { "-d": any, "-f": any, "-c": any } }),
302
325
  date: (args) => args.every((arg) => /^\+[A-Za-z0-9%:_./ -]*$/.test(arg)),
303
- echo: (args) => args.every((arg) => !arg.startsWith("-") || /^-[neE]+$/.test(arg)),
326
+ // No echo reads `---` as an option, so a separator such as `---files---` is printed as written.
327
+ echo: (args) => args.every((arg) => !arg.startsWith("-") || /^-[neE]+$/.test(arg) || arg.startsWith("---")),
304
328
  which: reads({}),
305
329
  diff: reads({ flags: letters("urqwbBN"), valued: { "-U": number } }),
330
+ // Printing a range of lines only. Any other script can write a file or run a command.
331
+ sed: ([quiet, script, ...files], project) => quiet === "-n" && /^(?:\d{1,7}|\$)(?:,(?:\d{1,7}|\$))?p$/.test(script ?? "")
332
+ && readArguments(files, {}, project) !== null,
306
333
  // A second file name is where `uniq` writes.
307
334
  uniq: (args, project) => (readArguments(args, { flags: letters("cdui") }, project) ?? [0, 0]).length <= 1,
308
335
  sort: reads({ flags: letters("bdfgnruV"), valued: { "-k": any, "-t": any } }),
@@ -328,6 +355,8 @@ const PROGRAMS = {
328
355
  return true;
329
356
  },
330
357
  git: ([verb, ...args], project) => {
358
+ // `-C` naming the project itself runs Git where it runs anyway. Any other folder may be another repository, with hooks of its own.
359
+ if (verb === "-C") return args[0]?.replace(/\/+$/, "") === project && PROGRAMS.git(args.slice(1), project);
331
360
  if (verb === "branch") return args.every((arg) => /^(?:--show-current|--list|--all|--remotes|-a|-r|-l|-v|-vv)$/.test(arg));
332
361
  if (verb === "tag") return args.every((arg) => /^(?:--list|-l)$/.test(arg));
333
362
  // One `--` may separate revisions from paths. What follows it is a path.
@@ -399,6 +428,200 @@ export function commandNeedsPerson(command, project) {
399
428
  }
400
429
  }
401
430
 
431
+ /**
432
+ * A branch or commit named in plain words. `FETCH_HEAD` is whatever was
433
+ * fetched last, which may be anyone's pull request.
434
+ */
435
+ function isRef(value) {
436
+ return /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/.test(value) && !/\.\.|\/\/|\/\.|\.lock(?:\/|$)|[/.]$/.test(value)
437
+ && value !== "FETCH_HEAD";
438
+ }
439
+
440
+ /** A jq filter that cannot read the environment, a file, or another input. */
441
+ const jqFilter = (value) => !/\$|\b(?:env|input|inputs|input_filename|import|include|debug|stderr|halt|halt_error|get_search_list)\b/.test(value);
442
+ const GH_OUTPUT = { "--json": (value) => /^[A-Za-z]+(?:,[A-Za-z]+)*$/.test(value), "--jq": jqFilter, "-q": jqFilter };
443
+ const ghState = (value) => /^(?:open|closed|merged|all)$/.test(value);
444
+ const ghLogin = (value) => /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(value);
445
+ // `gh` quotes a label. It puts search text inside parentheses that the text can close, so a search may reach any repository.
446
+ const ghLabel = (value) => /^[A-Za-z0-9][A-Za-z0-9 ._:/-]{0,49}$/.test(value);
447
+ const GH_LIST = { ...GH_OUTPUT, "--state": ghState, "-s": ghState, "--limit": number, "-L": number, "--label": ghLabel, "-l": ghLabel,
448
+ "--author": ghLogin, "-A": ghLogin, "--assignee": ghLogin, "-a": ghLogin };
449
+ const pullRequest = (value) => NUMBER.test(value) || isRef(value);
450
+
451
+ /**
452
+ * `gh` commands that only read this repository's pull requests and issues.
453
+ * There is no `--repo`, so `gh` reads the repository the project's remote
454
+ * names, and no `--web`, which opens a browser.
455
+ */
456
+ const GH_READS = {
457
+ pr: {
458
+ view: { spec: { flags: ["--comments", "-c"], valued: GH_OUTPUT, anywhere: true }, most: 1, target: pullRequest },
459
+ diff: { spec: { flags: ["--name-only", "--patch"], anywhere: true }, most: 1, target: pullRequest },
460
+ checks: { spec: { flags: ["--required"], valued: GH_OUTPUT, anywhere: true }, most: 1, target: pullRequest },
461
+ status: { spec: { valued: GH_OUTPUT, anywhere: true }, most: 0 },
462
+ list: { spec: { valued: { ...GH_LIST, "--head": isRef, "-H": isRef, "--base": isRef, "-B": isRef }, anywhere: true }, most: 0 },
463
+ },
464
+ issue: {
465
+ view: { spec: { flags: ["--comments", "-c"], valued: GH_OUTPUT, anywhere: true }, least: 1, most: 1, target: number },
466
+ list: { spec: { valued: GH_LIST, anywhere: true }, most: 0 },
467
+ },
468
+ };
469
+
470
+ function githubRead([noun, verb, ...args], project) {
471
+ const read = Object.hasOwn(GH_READS, noun ?? "") && Object.hasOwn(GH_READS[noun], verb ?? "") ? GH_READS[noun][verb] : null;
472
+ const words = read ? readArguments(args, read.spec, project) : null;
473
+ return words !== null && words.length >= (read.least ?? 0) && words.length <= read.most && words.every((word) => read.target(word));
474
+ }
475
+
476
+ /** A message given on the command line. Git opens an editor for one that is not. */
477
+ const message = (value) => typeof value === "string" && value.length > 0 && !value.startsWith("-");
478
+
479
+ /**
480
+ * Git commands that stage named files, commit, merge, fetch branches from
481
+ * `origin`, switch branches, or push to `origin`. Returns what the caller must
482
+ * still check: the branch the command creates or switches to and the branch
483
+ * it pushes to, which must be the agent's own, and the commit it merges or
484
+ * starts a branch from, which brings that commit's files into the project.
485
+ * Null when the command is not one of these.
486
+ */
487
+ function gitWork([verb, ...rest], project) {
488
+ if (verb === "add") {
489
+ const files = readArguments(rest, { flags: ["-u", "--update", "-v", "--verbose"], anywhere: true }, project);
490
+ // Files by name. A folder, a pattern, or a name that starts with a dot may hold more than the agent wrote.
491
+ return files?.every((file) => /^[A-Za-z0-9_][A-Za-z0-9_./-]*\.[A-Za-z0-9]+$/.test(file)
492
+ && file.split("/").every((part) => part !== "" && !part.startsWith("."))) ? {} : null;
493
+ }
494
+ if (verb === "commit") {
495
+ let given = false;
496
+ const options = [];
497
+ for (let at = 0; at < rest.length; at += 1) {
498
+ if (rest[at] !== "-m" && rest[at] !== "--message") options.push(rest[at]);
499
+ else if (message(rest[at + 1])) { given = true; at += 1; } else return null;
500
+ }
501
+ if (!options.every((option) => /^(?:-q|--quiet|-a|--all|--amend|--no-edit|--reset-author|-s|--signoff)$/.test(option))) return null;
502
+ return given || (options.includes("--amend") && options.includes("--no-edit")) ? {} : null;
503
+ }
504
+ if (verb === "checkout" || verb === "switch") {
505
+ const words = rest.filter((word) => word !== "-q" && word !== "--quiet");
506
+ // A new branch, from a named commit or from where the agent is now.
507
+ if (words[0] === (verb === "checkout" ? "-b" : "-c") && words.length >= 2 && words.length <= 3 && words.slice(1).every(isRef)) {
508
+ return { branch: words[1], ...(words[2] === undefined ? {} : { from: words[2] }) };
509
+ }
510
+ // An existing branch. `git checkout <name>` restores a file or folder of that name when no branch has it, so only `git switch`.
511
+ return verb === "switch" && words.length === 1 && isRef(words[0]) ? { branch: words[0] } : null;
512
+ }
513
+ if (verb === "merge") {
514
+ const words = [];
515
+ for (let at = 0; at < rest.length; at += 1) {
516
+ if (rest[at] === "-m") { if (!message(rest[at + 1])) return null; at += 1; }
517
+ else if (!/^(?:-q|--quiet|--no-edit|--ff|--ff-only|--no-ff|--no-stat)$/.test(rest[at])) words.push(rest[at]);
518
+ }
519
+ return words.length === 1 && isRef(words[0]) ? { from: words[0] } : null;
520
+ }
521
+ if (verb === "fetch") {
522
+ const words = readArguments(rest, { flags: ["-q", "--quiet", "--prune", "-p"], anywhere: true }, project);
523
+ // Branches of `origin` only. A pull request's ref may hold anyone's code.
524
+ return words?.[0] === "origin" && words.slice(1).every((ref) => isRef(ref) && !/^(?:pull|refs)\//.test(ref)) ? {} : null;
525
+ }
526
+ if (verb === "push") {
527
+ // No force, no deletion, and no tags. The branch it goes to is named after `:`, so neither the branch checked
528
+ // out, nor its upstream, nor any push setting decides where it goes.
529
+ const words = readArguments(rest, { flags: ["-u", "--set-upstream", "-q", "--quiet"], anywhere: true }, project);
530
+ if (words?.length !== 2 || words[0] !== "origin") return null;
531
+ const [source, destination, extra] = words[1].split(":");
532
+ return extra === undefined && destination !== undefined && isRef(source) && isRef(destination)
533
+ ? { push: destination.startsWith("refs/heads/") ? destination.slice("refs/heads/".length) : destination } : null;
534
+ }
535
+ return null;
536
+ }
537
+
538
+ /**
539
+ * True when each simple command in each part is, word for word, one in the
540
+ * whole command. A part another parser found that these rules did not is a
541
+ * reading of the command they have not judged.
542
+ */
543
+ export function partsAreInCommand(command, parts) {
544
+ try {
545
+ if (!isText(command, PERMISSION_REVIEW_MAX_COMMAND_CHARS) || !Array.isArray(parts)) return false;
546
+ const key = (simple) => JSON.stringify(simple.words.map((word) => [word.value, word.quoted && simple.words[0] === word]));
547
+ const whole = new Set((readCommands(command) ?? []).map(key));
548
+ return whole.size > 0 && parts.every((part) => {
549
+ const simples = isText(part, PERMISSION_REVIEW_MAX_COMMAND_CHARS) ? readCommands(part) : null;
550
+ return simples !== null && simples.every((simple) => whole.has(key(simple)));
551
+ });
552
+ } catch {
553
+ return false;
554
+ }
555
+ }
556
+
557
+ /**
558
+ * True when every part of the command only reads: files inside the project,
559
+ * the repository's history, or its pull requests and issues. Nothing that
560
+ * runs the project's own code, which may write anything.
561
+ */
562
+ export function commandOnlyReads(command, project) {
563
+ try {
564
+ if (!isText(command, PERMISSION_REVIEW_MAX_COMMAND_CHARS)) return false;
565
+ const root = normalizedProject(project);
566
+ const commands = root ? readCommands(command) : null;
567
+ return commands !== null && commands.every((simple) => (simpleCommandAllowed(simple, root) && !RUNS_CODE.has(simple.words[0].value))
568
+ || (!simple.words[0].quoted && simple.words[0].value === "gh" && githubRead(simple.words.slice(1).map((word) => word.value), root)));
569
+ } catch {
570
+ return false;
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Review a command from an agent that works on its own branches, as far as
576
+ * these rules can. Returns null when a person must decide. Otherwise returns
577
+ * the parts Jev must still review, each as written: none when the rules alone
578
+ * allow the whole command.
579
+ *
580
+ * The rules allow on their own what Jev would call a change or a network call
581
+ * but is routine for such an agent: reading this repository's pull requests
582
+ * and issues, and staging, committing, fetching from `origin`, merging
583
+ * `origin`'s default branch or its own, and creating, switching to, and
584
+ * pushing to its own branches. Reading and the project's checks still go to Jev, as written.
585
+ *
586
+ * `ownBranch` says whether a branch is the agent's own. `defaultBranch` is the
587
+ * branch `origin` names as its default, or null when it is not known. A merge
588
+ * or a new branch may start only from `origin`'s copy of that branch, the
589
+ * agent's own, or where it is: any other branch, the local default branch
590
+ * included, may bring in settings and hooks that no one reviewed.
591
+ */
592
+ export function routineCommandReview(command, project, context) {
593
+ try {
594
+ if (!isText(command, PERMISSION_REVIEW_MAX_COMMAND_CHARS)) return null;
595
+ const root = normalizedProject(project);
596
+ const commands = root ? readCommands(command) : null;
597
+ if (!commands) return null;
598
+ const own = (branch) => typeof branch === "string" && context.ownBranch(branch) === true;
599
+ const base = typeof context.defaultBranch === "string" && isRef(context.defaultBranch) ? context.defaultBranch : null;
600
+ const trusted = (ref) => ref === "HEAD" || own(ref) || (ref.startsWith("origin/") && own(ref.slice("origin/".length)))
601
+ || (base !== null && ref === `origin/${base}`);
602
+ const review = [];
603
+ let decided = false;
604
+ for (const simple of commands) {
605
+ if (simpleCommandAllowed(simple, root)) {
606
+ review.push(command.slice(simple.start, simple.end));
607
+ continue;
608
+ }
609
+ decided = true;
610
+ const [program, ...words] = simple.words;
611
+ const args = words.map((word) => word.value);
612
+ if (!program.quoted && program.value === "gh" && githubRead(args, root)) continue;
613
+ const work = !program.quoted && program.value === "git" ? gitWork(args, root) : null;
614
+ if (!work || (work.branch !== undefined && !own(work.branch)) || (work.push !== undefined && !own(work.push))
615
+ || (work.from !== undefined && !trusted(work.from))) return null;
616
+ }
617
+ // A command the rules decided no part of is reviewed whole, as it always was.
618
+ if (!decided) return [command];
619
+ return review.length <= PERMISSION_REVIEW_MAX_COMMANDS ? review : null;
620
+ } catch {
621
+ return null;
622
+ }
623
+ }
624
+
402
625
  const KINDS = ["read", "check", "edit", "risky"];
403
626
 
404
627
  const QUESTIONS = {
@@ -0,0 +1,25 @@
1
+ export const LETAGENTS_ROOM_ORIGIN: string;
2
+ export const MESSAGE_ID_PATTERN: RegExp;
3
+
4
+ export function isValidMessageId(messageId: unknown): messageId is string;
5
+ export function encodeRoomPathIdentifier(identifier: string): string;
6
+ export function decodeRoomPath(path: string): string;
7
+ export function isLocalRoomIdentifier(identifier: string | null | undefined): boolean;
8
+
9
+ export function buildLetAgentsMessageUrl(
10
+ roomIdentifier: string,
11
+ messageId: string,
12
+ origin?: string,
13
+ ): string;
14
+
15
+ export function parseLetAgentsMessageUrl(
16
+ rawUrl: string,
17
+ configuredOrigin?: string,
18
+ ): { roomIdentifier: string; messageId: string | null } | null;
19
+
20
+ export function resolveSameRoomMessageReference(
21
+ rawUrl: string | null | undefined,
22
+ currentRoomIdentifier: string | null | undefined,
23
+ configuredOrigin?: string,
24
+ ): string | null;
25
+
@@ -0,0 +1,119 @@
1
+ export const LETAGENTS_ROOM_ORIGIN = "https://letagents.chat";
2
+
3
+ export const MESSAGE_ID_PATTERN = /^msg_[1-9]\d*$/;
4
+
5
+ export function isValidMessageId(messageId) {
6
+ return typeof messageId === "string" && MESSAGE_ID_PATTERN.test(messageId);
7
+ }
8
+
9
+ export function encodeRoomPathIdentifier(identifier) {
10
+ return String(identifier ?? "")
11
+ .split("/")
12
+ .map((segment) => encodeURIComponent(segment))
13
+ .join("/");
14
+ }
15
+
16
+ export function decodeRoomPath(path) {
17
+ return String(path ?? "")
18
+ .split("/")
19
+ .map((segment) => {
20
+ try {
21
+ return decodeURIComponent(segment);
22
+ } catch {
23
+ return segment;
24
+ }
25
+ })
26
+ .join("/");
27
+ }
28
+
29
+ export function isLocalRoomIdentifier(identifier) {
30
+ const value = String(identifier ?? "").trim();
31
+ return /^local[_-]/i.test(value) || /^git-room:local:/i.test(value);
32
+ }
33
+
34
+ export function buildLetAgentsMessageUrl(
35
+ roomIdentifier,
36
+ messageId,
37
+ origin = LETAGENTS_ROOM_ORIGIN,
38
+ ) {
39
+ const cleanId = String(messageId ?? "").trim();
40
+ if (!isValidMessageId(cleanId)) {
41
+ throw new Error(`Invalid message ID for permalink: ${messageId}`);
42
+ }
43
+ const cleanRoom = String(roomIdentifier ?? "").trim();
44
+ if (!cleanRoom) {
45
+ throw new Error("Room identifier is required for permalink.");
46
+ }
47
+ const base = origin ? origin.replace(/\/+$/, "") : "";
48
+ return `${base}/in/${encodeRoomPathIdentifier(cleanRoom)}?message=${cleanId}`;
49
+ }
50
+
51
+ export function parseLetAgentsMessageUrl(
52
+ rawUrl,
53
+ configuredOrigin = LETAGENTS_ROOM_ORIGIN,
54
+ ) {
55
+ if (typeof rawUrl !== "string") return null;
56
+ const trimmed = rawUrl.trim();
57
+ if (!trimmed) return null;
58
+
59
+ let parsed;
60
+ let isRelative = false;
61
+ try {
62
+ parsed = new URL(trimmed);
63
+ } catch {
64
+ if (trimmed.startsWith("/in/") || trimmed.startsWith("in/")) {
65
+ try {
66
+ parsed = new URL(trimmed.startsWith("/") ? trimmed : `/${trimmed}`, "http://localhost");
67
+ isRelative = true;
68
+ } catch {
69
+ return null;
70
+ }
71
+ } else {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ if (!isRelative) {
77
+ let normalizedConfiguredOrigin;
78
+ try {
79
+ normalizedConfiguredOrigin = new URL(configuredOrigin).origin.toLowerCase();
80
+ } catch {
81
+ normalizedConfiguredOrigin = String(configuredOrigin).replace(/\/+$/, "").toLowerCase();
82
+ }
83
+ if (parsed.origin.toLowerCase() !== normalizedConfiguredOrigin) {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ const pathname = parsed.pathname.replace(/^\/+|\/+$/g, "");
89
+ if (!pathname.startsWith("in/")) {
90
+ return null;
91
+ }
92
+ const rawRoom = pathname.slice("in/".length);
93
+ if (!rawRoom) return null;
94
+ const roomIdentifier = decodeRoomPath(rawRoom);
95
+ if (!roomIdentifier.trim()) return null;
96
+
97
+ const rawMessageId = parsed.searchParams.get("message");
98
+ const messageId = isValidMessageId(rawMessageId) ? rawMessageId.trim() : null;
99
+
100
+ return {
101
+ roomIdentifier,
102
+ messageId,
103
+ };
104
+ }
105
+
106
+ export function resolveSameRoomMessageReference(
107
+ rawUrl,
108
+ currentRoomIdentifier,
109
+ configuredOrigin = LETAGENTS_ROOM_ORIGIN,
110
+ ) {
111
+ if (typeof rawUrl !== "string" || !currentRoomIdentifier) return null;
112
+ const parsed = parseLetAgentsMessageUrl(rawUrl, configuredOrigin);
113
+ if (!parsed?.messageId) return null;
114
+ if (parsed.roomIdentifier.trim() !== String(currentRoomIdentifier).trim()) {
115
+ return null;
116
+ }
117
+ return parsed.messageId;
118
+ }
119
+
@@ -0,0 +1,18 @@
1
+ import type { RoomNotificationPreference, RoomNotificationPreferenceChange, RoomNotificationPreferenceEntry, RoomNotificationPreferenceList } from './room-notification-preferences.mjs';
2
+ export interface RoomNotificationClientState {
3
+ preference: RoomNotificationPreference; ready: boolean; loading: boolean; busy: boolean; error: string; version: number;
4
+ }
5
+ export declare function createRoomNotificationClient(api: {
6
+ get(roomId: string): Promise<RoomNotificationPreferenceEntry>;
7
+ list?(): Promise<RoomNotificationPreferenceList>;
8
+ put?(roomId: string, change: RoomNotificationPreferenceChange): Promise<RoomNotificationPreferenceEntry>;
9
+ }, changed?: () => void, normalizeKey?: (roomId: string) => string): {
10
+ setViewer(viewer: { id: string; login: string } | null): void;
11
+ signedIn(): boolean;
12
+ allowsAfterRead(roomId: string, text: unknown): Promise<boolean>;
13
+ state(roomId: string): RoomNotificationClientState;
14
+ refresh(roomId: string): Promise<void>;
15
+ refreshAll(): Promise<void>;
16
+ update(roomId: string, change: RoomNotificationPreferenceChange): Promise<void>;
17
+ allows(roomId: string, text: unknown, now?: number): boolean;
18
+ };
@@ -0,0 +1,140 @@
1
+ import { allowsRoomNotification, DEFAULT_ROOM_NOTIFICATION_PREFERENCE } from './room-notification-preferences.mjs';
2
+
3
+ /** Personal cache shared by desktop and the read-only web sound gate. */
4
+ export function createRoomNotificationClient(api, changed = () => {}, normalizeKey = (id) => id) {
5
+ let viewer = null;
6
+ let epoch = 0;
7
+ let listVersion = 0;
8
+ let listLoading = false;
9
+ const rooms = new Map();
10
+ const pendingReads = new Map();
11
+ let pendingList = null;
12
+ function state(roomId) {
13
+ roomId = normalizeKey(roomId);
14
+ if (!rooms.has(roomId)) rooms.set(roomId, {
15
+ preference: { ...DEFAULT_ROOM_NOTIFICATION_PREFERENCE }, ready: false, loading: false,
16
+ busy: false, error: '', version: 0,
17
+ });
18
+ return rooms.get(roomId);
19
+ }
20
+ function setViewer(next) {
21
+ if (viewer?.id === next?.id && viewer?.login === next?.login) return;
22
+ viewer = next;
23
+ epoch++;
24
+ listVersion++;
25
+ listLoading = false;
26
+ rooms.clear();
27
+ pendingReads.clear();
28
+ pendingList = null;
29
+ changed();
30
+ }
31
+ async function performRefresh(roomId) {
32
+ if (!viewer || !roomId) return;
33
+ const row = state(roomId);
34
+ if (row.busy) return;
35
+ const version = ++row.version;
36
+ const currentEpoch = epoch;
37
+ row.loading = true;
38
+ changed();
39
+ try {
40
+ const preference = await api.get(roomId);
41
+ if (epoch !== currentEpoch || row.version !== version) return;
42
+ row.preference = preference;
43
+ row.error = '';
44
+ } catch (error) {
45
+ if (epoch !== currentEpoch || row.version !== version) return;
46
+ if (!row.ready) row.preference = { ...DEFAULT_ROOM_NOTIFICATION_PREFERENCE };
47
+ row.error = error instanceof Error ? error.message : 'Notification settings could not be loaded.';
48
+ } finally {
49
+ if (epoch === currentEpoch && row.version === version) {
50
+ row.loading = false;
51
+ row.ready = true;
52
+ changed();
53
+ }
54
+ }
55
+ }
56
+ function refresh(roomId) {
57
+ roomId = normalizeKey(roomId);
58
+ const pending = performRefresh(roomId);
59
+ pendingReads.set(roomId, pending);
60
+ void pending.then(() => { if (pendingReads.get(roomId) === pending) pendingReads.delete(roomId); });
61
+ return pending;
62
+ }
63
+ async function performRefreshAll() {
64
+ if (!viewer || !api.list) return;
65
+ const currentEpoch = epoch;
66
+ const version = ++listVersion;
67
+ listLoading = true;
68
+ const roomVersions = new Map([...rooms].map(([id, row]) => [id, row.version]));
69
+ try {
70
+ const result = await api.list();
71
+ if (currentEpoch !== epoch || version !== listVersion) return;
72
+ const entries = new Map(result.preferences.map((entry) => [normalizeKey(entry.room_id), entry]));
73
+ for (const id of new Set([...rooms.keys(), ...entries.keys()])) {
74
+ const row = state(id);
75
+ if (row.version !== (roomVersions.get(id) ?? 0) || row.loading || row.busy) continue;
76
+ if (!entries.has(id) && result.truncated) continue;
77
+ row.preference = entries.get(id) ?? { ...DEFAULT_ROOM_NOTIFICATION_PREFERENCE };
78
+ row.ready = true;
79
+ row.error = '';
80
+ }
81
+ } catch (error) {
82
+ if (currentEpoch !== epoch || version !== listVersion) return;
83
+ for (const [id, row] of rooms) {
84
+ if (row.version !== (roomVersions.get(id) ?? 0) || row.loading || row.busy) continue;
85
+ if (!row.ready) row.preference = { ...DEFAULT_ROOM_NOTIFICATION_PREFERENCE };
86
+ row.ready = true;
87
+ row.error = error instanceof Error ? error.message : 'Notification settings could not be loaded.';
88
+ }
89
+ }
90
+ if (version === listVersion && currentEpoch === epoch) listLoading = false;
91
+ changed();
92
+ }
93
+ function refreshAll() {
94
+ const pending = performRefreshAll();
95
+ pendingList = pending;
96
+ void pending.then(() => { if (pendingList === pending) pendingList = null; });
97
+ return pending;
98
+ }
99
+ async function update(roomId, change) {
100
+ roomId = normalizeKey(roomId);
101
+ if (!viewer || !api.put) throw new Error('Sign in to manage your room notifications.');
102
+ const row = state(roomId);
103
+ if (row.busy) return;
104
+ row.busy = true;
105
+ row.loading = false;
106
+ row.version++;
107
+ listVersion++;
108
+ listLoading = false;
109
+ const currentEpoch = epoch;
110
+ changed();
111
+ try {
112
+ const preference = await api.put(roomId, change);
113
+ if (currentEpoch !== epoch) return;
114
+ row.preference = preference;
115
+ row.ready = true;
116
+ row.error = '';
117
+ } catch (error) {
118
+ if (currentEpoch !== epoch) return;
119
+ row.error = error instanceof Error ? error.message : 'Notification settings could not be saved.';
120
+ } finally {
121
+ if (currentEpoch === epoch) { row.busy = false; changed(); }
122
+ }
123
+ }
124
+ function allows(roomId, text, now = Date.now()) {
125
+ const row = state(roomId);
126
+ if ((row.loading || listLoading) && !row.ready) return false;
127
+ return allowsRoomNotification(row.preference, text, viewer?.login, now);
128
+ }
129
+ async function allowsAfterRead(roomId, text) {
130
+ roomId = normalizeKey(roomId);
131
+ const currentEpoch = epoch;
132
+ while (currentEpoch === epoch && !state(roomId).ready) {
133
+ const pending = pendingReads.get(roomId) ?? pendingList;
134
+ if (!pending) break;
135
+ await pending;
136
+ }
137
+ return currentEpoch === epoch && allows(roomId, text);
138
+ }
139
+ return { setViewer, state, refresh, refreshAll, update, allows, allowsAfterRead, signedIn: () => Boolean(viewer) };
140
+ }
@@ -0,0 +1,17 @@
1
+ export type RoomNotificationLevel = 'all' | 'mentions' | 'muted';
2
+ export interface RoomNotificationPreference {
3
+ level: RoomNotificationLevel;
4
+ snoozed_until: string | null;
5
+ }
6
+ export interface RoomNotificationPreferenceEntry extends RoomNotificationPreference { room_id: string }
7
+ export interface RoomNotificationPreferenceList { preferences: RoomNotificationPreferenceEntry[]; truncated: boolean }
8
+ export type RoomNotificationPreferenceChange = Partial<RoomNotificationPreference>;
9
+ export type RoomNotificationSnoozePreset = '1h' | '8h' | 'tomorrow';
10
+ export declare const ROOM_NOTIFICATION_LEVELS: readonly RoomNotificationLevel[];
11
+ export declare const DEFAULT_ROOM_NOTIFICATION_PREFERENCE: Readonly<RoomNotificationPreference>;
12
+ export declare const PERSON_MENTION_START: string;
13
+ export declare const PERSON_MENTION_END: string;
14
+ export declare function mentionsPerson(text: unknown, login: string | null | undefined): boolean;
15
+ export declare function roomNotificationsSuppressed(preference: RoomNotificationPreference | null | undefined, now?: number): boolean;
16
+ export declare function allowsRoomNotification(preference: RoomNotificationPreference | null | undefined, text: unknown, login: string | null | undefined, now?: number): boolean;
17
+ export declare function roomNotificationSnoozeUntil(preset: RoomNotificationSnoozePreset, now?: Date): string;