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.
- package/README.md +0 -2
- package/dist/mcp/codex-session/app-server.js +16 -2
- package/dist/mcp/codex-session/session-start.js +1 -1
- package/dist/mcp/server/runtime/messages.js +18 -1
- package/dist/mcp/server/tools/tasks/verdict-tools.js +1 -1
- package/dist/mcp/server/tools/wake-rules.js +2 -1
- package/dist/shared/presence-change-channel.js +2 -0
- package/package.json +2 -2
- package/shared/agent-branch.d.mts +5 -0
- package/shared/agent-branch.mjs +28 -0
- package/shared/code-block.css +193 -0
- package/shared/code-highlighting.d.mts +109 -0
- package/shared/code-highlighting.mjs +345 -0
- package/shared/codex-owner-isolation.d.mts +34 -0
- package/shared/codex-owner-isolation.mjs +210 -0
- package/shared/message-link-preview-store.d.mts +10 -0
- package/shared/message-link-preview-store.mjs +77 -0
- package/shared/message-link-previews.d.mts +16 -0
- package/shared/message-link-previews.mjs +70 -0
- package/shared/message-pin-store.d.mts +20 -0
- package/shared/message-pin-store.mjs +102 -0
- package/shared/message-pins.d.mts +25 -0
- package/shared/message-pins.mjs +16 -0
- package/shared/message-reaction-store.d.mts +51 -0
- package/shared/message-reaction-store.mjs +290 -0
- package/shared/message-reactions.d.mts +73 -0
- package/shared/message-reactions.mjs +130 -0
- package/shared/message-search-controller.d.mts +38 -0
- package/shared/message-search-controller.mjs +99 -0
- package/shared/message-search.d.mts +46 -0
- package/shared/message-search.mjs +114 -0
- package/shared/paced-queue.d.mts +28 -0
- package/shared/paced-queue.mjs +159 -0
- package/shared/permission-review.d.mts +19 -0
- package/shared/permission-review.mjs +238 -15
- package/shared/room-message-urls.d.mts +25 -0
- package/shared/room-message-urls.mjs +119 -0
- package/shared/room-notification-client.d.mts +18 -0
- package/shared/room-notification-client.mjs +140 -0
- package/shared/room-notification-preferences.d.mts +17 -0
- package/shared/room-notification-preferences.mjs +34 -0
- package/shared/room-resource-invalidation.d.mts +5 -0
- package/shared/room-resource-invalidation.mjs +6 -0
- package/shared/room-turn-no-reply.d.mts +1 -0
- package/shared/room-turn-no-reply.mjs +1 -0
- package/shared/room-typing.d.mts +16 -0
- package/shared/room-typing.mjs +112 -0
- package/shared/room-unread-client.ts +128 -0
- package/shared/room-unread.d.mts +25 -0
- package/shared/room-unread.mjs +57 -0
- package/shared/room-unread.test.mjs +91 -0
- package/shared/rooms/RoomsDirectory.vue +1 -1
- package/shared/rooms/directory.ts +1 -0
- package/shared/slash-commands.d.mts +31 -0
- package/shared/slash-commands.mjs +48 -0
- package/shared/slash-commands.test.mjs +26 -0
- package/shared/ui/MessageReactionBar.vue +65 -0
- package/shared/ui/MessageReactionPicker.vue +291 -0
- package/shared/ui/MessageSearchResults.vue +106 -0
- package/shared/ui/PinnedMessages.vue +124 -0
- package/shared/ui/TypingIndicator.vue +22 -0
- package/shared/ui/WakeRulesPanel.vue +1 -0
- package/shared/ui/message-reactions.css +254 -0
- package/shared/ui/message-search.css +186 -0
- package/shared/ui/useComposerSlashCommands.ts +92 -0
- package/shared/wake-rules.d.mts +8 -3
- 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
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
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;
|