letagents 0.12.30 → 0.12.32

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 (53) hide show
  1. package/README.md +1 -1
  2. package/dist/mcp/local-state/storage.js +0 -5
  3. package/dist/mcp/server/register-tools.js +2 -0
  4. package/dist/mcp/server/runtime/api.js +53 -1
  5. package/dist/mcp/server/runtime/identity/config.js +30 -1
  6. package/dist/mcp/server/runtime/identity/liveness.js +19 -1
  7. package/dist/mcp/server/runtime/process-connection.js +239 -0
  8. package/dist/mcp/server/runtime/worker-bearer.js +0 -4
  9. package/dist/mcp/server/runtime/worker-handles.js +109 -15
  10. package/dist/mcp/server/runtime.js +1 -1
  11. package/dist/mcp/server/supervised-tool-facade.js +2 -17
  12. package/dist/mcp/server/tools/agent-sessions.js +38 -6
  13. package/dist/mcp/server/tools/knowledge.js +29 -0
  14. package/dist/mcp/server/tools/messages/wait-tool.js +1 -1
  15. package/dist/mcp/server/tools/rooms/inspection-tools.js +1 -1
  16. package/dist/mcp/server/tools/wake-rules.js +96 -0
  17. package/dist/mcp/server.js +29 -7
  18. package/dist/mcp/worker-call-context.js +4 -0
  19. package/dist/shared/agent-identity.js +4 -0
  20. package/dist/shared/agent-presence.js +141 -0
  21. package/dist/shared/codenames.js +2 -104
  22. package/dist/shared/handoff.js +0 -6
  23. package/dist/shared/request-headers.js +11 -0
  24. package/dist/shared/room-agent-prompts.js +1 -1
  25. package/package.json +4 -5
  26. package/shared/activation-routing.d.mts +6 -1
  27. package/shared/activation-routing.mjs +10 -1
  28. package/shared/agent-codenames.d.mts +17 -0
  29. package/shared/agent-codenames.mjs +96 -0
  30. package/shared/daemon-maintenance.d.mts +13 -0
  31. package/shared/daemon-maintenance.mjs +99 -0
  32. package/shared/permission-review.d.mts +30 -0
  33. package/shared/permission-review.mjs +482 -0
  34. package/shared/provider-acquisition-evidence.d.mts +15 -0
  35. package/shared/provider-acquisition-evidence.mjs +33 -0
  36. package/shared/room-agent-work.d.mts +1 -0
  37. package/shared/room-agent-work.mjs +11 -0
  38. package/shared/room-resource-invalidation.d.mts +3 -0
  39. package/shared/room-resource-invalidation.mjs +2 -0
  40. package/shared/room-settings.d.mts +48 -0
  41. package/shared/room-settings.mjs +108 -0
  42. package/shared/scratch-workspace-repository.d.mts +13 -0
  43. package/shared/scratch-workspace-repository.mjs +213 -0
  44. package/shared/supervised-read-tools.d.mts +2 -0
  45. package/shared/supervised-read-tools.mjs +25 -0
  46. package/shared/ui/PrivateMessages.vue +12 -17
  47. package/shared/ui/WakeGlyph.vue +55 -0
  48. package/shared/ui/WakeRuleLine.vue +51 -0
  49. package/shared/ui/WakeRulesPanel.vue +201 -0
  50. package/shared/ui/private-messages.css +35 -25
  51. package/shared/ui/wake-rules.css +273 -0
  52. package/shared/wake-rules.d.mts +145 -0
  53. package/shared/wake-rules.mjs +425 -0
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Automatic review of an agent's shell commands.
3
+ *
4
+ * Two layers decide whether a command may run without asking a person:
5
+ *
6
+ * 1. Fixed rules in this file. They allow only command shapes they can read
7
+ * in full: a short list of programs, written in plain words, each with
8
+ * the options it is known to take, and arguments that stay inside the
9
+ * project. Everything else goes to a person and is never sent to a model.
10
+ * 2. Jev, a decision model, sorts what is left by kind. Reading and the
11
+ * project's own checks may run.
12
+ *
13
+ * The rules name what is allowed, not what is forbidden, because a shell and
14
+ * its programs can spell a forbidden thing in more ways than a list can hold.
15
+ * They are still best effort: they read the command's text, not what a
16
+ * program does with it. A project's own scripts and tests run whatever the
17
+ * project defines.
18
+ *
19
+ * Every other outcome (no rule allows it, a low score, a missing or malformed
20
+ * answer, a timeout) means "ask a person". Nothing here ever denies a command
21
+ * or decides that a command is dangerous: it only declines to decide.
22
+ *
23
+ * Pure: no I/O, no clock, no environment.
24
+ */
25
+
26
+ /** How much of the answer must fall on reading or on the project's own checks. */
27
+ export const PERMISSION_REVIEW_APPROVE_AT = 0.9;
28
+ /** How much of the answer may fall on risky. */
29
+ export const PERMISSION_REVIEW_RISKY_AT_MOST = 0.1;
30
+ export const PERMISSION_REVIEW_MAX_COMMANDS = 16;
31
+ export const PERMISSION_REVIEW_MAX_COMMAND_CHARS = 2_000;
32
+ export const PERMISSION_REVIEW_MAX_PROJECT_CHARS = 1_024;
33
+
34
+ /** The scores of the four kinds must add up to a whole, give or take this. */
35
+ const KIND_TOTAL_TOLERANCE = 0.02;
36
+
37
+ /** Characters a word may hold outside quotes. None of them means anything to a shell. */
38
+ const PLAIN = /^[A-Za-z0-9_\-./:=@,+%~]$/;
39
+ /** Inside single quotes nothing is special, so any printable character but the quote itself. */
40
+ const SINGLE_QUOTED = /^[\x20-\x26\x28-\x7e]*$/;
41
+ /** Inside double quotes a shell still expands `$`, backticks, `\` and `!`. */
42
+ const DOUBLE_QUOTED = /^[\x20\x23\x25-\x5b\x5d-\x5f\x61-\x7e]*$/;
43
+ /** Joining or discarding an output stream names no file a command could overwrite. */
44
+ const STREAM = /^(?:2>&1|[12]?>>? ?\/dev\/null)(?=[ \t\n;|&]|$)/;
45
+
46
+ const CREDENTIAL = /(?:^|[/=:,])\.(?:env|envrc|ssh|aws|gnupg|kube|docker|dockercfg|npmrc|pypirc|netrc|pgpass|git-credentials|azure|boto|s3cfg|my\.cnf)(?:$|[/._-])|\.(?:env|pem|key|crt|cer|p12|pfx|jks|keystore|kdbx|tfstate|tfvars)(?:$|[.,:])|credential|secret|passw|token|api[_-]?key|private[_-]?key|service[_-]?account|kubeconfig|wallet|_history|id_(?:rsa|dsa|ecdsa|ed25519)|authorized_keys|known_hosts|keychain/i;
47
+
48
+ /** A script, target, or file named for an action that ships, removes, or reaches production. */
49
+ const CONSEQUENTIAL_NAME = /deploy|release|publish|rollback|destroy|teardown|wipe|purge|drop|nuke|erase|delete|remove|reset|clean|clear|uninstall|install|migrat|seed|prod|backup|restore|upload|email|notify|charge|refund|invoice|payment|(?:^|[^a-z])(?:env|db|sync|ship|push|rm)(?:$|[^a-z])/i;
50
+
51
+ function isText(value, max) {
52
+ return typeof value === "string" && value.length > 0 && value.length <= max;
53
+ }
54
+
55
+ function normalizedProject(project) {
56
+ if (!isText(project, PERMISSION_REVIEW_MAX_PROJECT_CHARS) || !/^[\x21-\x7e]+$/.test(project)) return null;
57
+ const trimmed = project.replace(/\/+$/, "");
58
+ return trimmed.startsWith("/") && trimmed.length > 1
59
+ && trimmed.split("/").slice(1).every((part) => part && part !== "." && part !== "..") ? trimmed : null;
60
+ }
61
+
62
+ /**
63
+ * Split a command into simple commands of words, or return null when any part
64
+ * of it is written in a way these rules do not read: expansion, substitution,
65
+ * escaping, grouping, a redirect to a file, a background job, a comment, or a
66
+ * quote joined to other text.
67
+ */
68
+ function readCommands(command) {
69
+ // Printable ASCII, tab, and new line only. Anything else can look like a space and not be one.
70
+ if (!/^[\x20-\x7e\t\n]+$/.test(command)) return null;
71
+ const commands = [{ words: [], fed: false }];
72
+ const current = () => commands[commands.length - 1];
73
+ const boundary = (index) => index >= command.length || " \t\n;|&".includes(command[index]);
74
+ let index = 0;
75
+ while (index < command.length) {
76
+ const char = command[index];
77
+ if (char === " " || char === "\t") { index += 1; continue; }
78
+ const rest = command.slice(index);
79
+ const stream = STREAM.exec(rest);
80
+ if (stream) { index += stream[0].length; continue; }
81
+ if (char === "\n") {
82
+ if (current().words.length > 0) commands.push({ words: [], fed: false });
83
+ index += 1;
84
+ continue;
85
+ }
86
+ const operator = /^(?:&&|\|\||;|\|)/.exec(rest)?.[0];
87
+ if (operator) {
88
+ // An operator joins two commands. One with nothing before it, or `|&`, `;;`, `&`, is not read.
89
+ if (current().words.length === 0 || /^[|;&]/.test(rest.slice(operator.length))) return null;
90
+ // What comes through a pipe is the next program's input, and some programs run their input.
91
+ commands.push({ words: [], fed: operator === "|" });
92
+ index += operator.length;
93
+ continue;
94
+ }
95
+ 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;
102
+ continue;
103
+ }
104
+ let end = index;
105
+ 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);
108
+ // A shell expands `~` at the start of a word and after `=` or `:`, and zsh expands a leading `=`.
109
+ if (/(?:^|[=:])~|^=/.test(value)) return null;
110
+ current().words.push({ value, quoted: false });
111
+ index = end;
112
+ }
113
+ // Only `;` or a new line may end a command. `&&`, `||`, and `|` leave the shell waiting for more.
114
+ if (current().words.length === 0) {
115
+ if (/(?:&&|\|\||\|)[ \t\n]*$/.test(command)) return null;
116
+ commands.pop();
117
+ }
118
+ return commands.length > 0 ? commands : null;
119
+ }
120
+
121
+ /** True when the text names nothing outside the project and nothing that holds credentials or history. */
122
+ function staysInProject(value, project) {
123
+ // `@file` makes several programs read their arguments from the file.
124
+ if (value.includes("://") || /(?:^|[=:,])@/.test(value) || CREDENTIAL.test(value)) return false;
125
+ for (const piece of value.split(/[=:,]/)) {
126
+ if (piece.startsWith("~")) return false;
127
+ const parts = piece.split("/");
128
+ if (parts.some((part) => part === ".." || part.toLowerCase() === ".git")) return false;
129
+ if (piece.startsWith("/")) {
130
+ const path = piece.replace(/\/+$/, "") || "/";
131
+ if (parts.slice(1, -1).some((part) => part === "" || part === ".")) return false;
132
+ if (path !== project && !path.startsWith(`${project}/`)) return false;
133
+ }
134
+ }
135
+ return true;
136
+ }
137
+
138
+ const NUMBER = /^\d{1,6}$/;
139
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9:_.-]*$/;
140
+ const SOURCE_FILE = /^[A-Za-z0-9_.][A-Za-z0-9_./-]*\.(?:[cm]?js|[cm]?ts|tsx|jsx)$/;
141
+ const PYTHON_FILE = /^[A-Za-z0-9_.][A-Za-z0-9_./-]*\.py$/;
142
+
143
+ const any = () => true;
144
+ const number = (value) => NUMBER.test(value);
145
+ const name = (value) => NAME.test(value);
146
+ const plainName = (value) => NAME.test(value) && !CONSEQUENTIAL_NAME.test(value);
147
+ const unremarkable = (value) => !CONSEQUENTIAL_NAME.test(value);
148
+ const letters = (text) => [...text].map((letter) => `-${letter}`);
149
+
150
+ /**
151
+ * Read one program's arguments against what it is known to take, and return
152
+ * its plain words, or null when anything is not known.
153
+ *
154
+ * `flags` stand alone, and one-letter flags may be written together.
155
+ * `valued` options take the next word, which must pass the given test. A
156
+ * `--long` option may take `=value` instead, and so may a `-long` one when
157
+ * `dashed` is set, as Go writes them. `count` allows `-20` as a number of
158
+ * lines. Options come before the first plain word unless `anywhere` is set.
159
+ */
160
+ function readArguments(args, spec, project) {
161
+ const flags = new Set(spec.flags ?? []);
162
+ const valued = spec.valued ?? {};
163
+ const words = [];
164
+ for (let index = 0; index < args.length; index += 1) {
165
+ const arg = args[index];
166
+ // `-` is the input, and `--` would hide an option from what follows.
167
+ if (arg === "-" || arg === "--") return null;
168
+ if (!arg.startsWith("-")) {
169
+ if (!staysInProject(arg, project)) return null;
170
+ words.push(arg);
171
+ continue;
172
+ }
173
+ if (words.length > 0 && spec.anywhere !== true) return null;
174
+ if (spec.count === true && NUMBER.test(arg.slice(1))) continue;
175
+ const assigned = (spec.dashed === true ? /^(--?[A-Za-z][A-Za-z0-9-]*)=(.*)$/ : /^(--[A-Za-z][A-Za-z0-9-]*)=(.*)$/).exec(arg);
176
+ // `-n=20` gives most programs the value `=20`, which is not what these rules would read.
177
+ if (!assigned && arg.includes("=")) return null;
178
+ const option = assigned ? assigned[1] : arg;
179
+ if (Object.hasOwn(valued, option)) {
180
+ const value = assigned ? assigned[2] : args[index += 1];
181
+ if (typeof value !== "string" || value === "" || value.startsWith("-")
182
+ || !staysInProject(value, project) || valued[option](value) !== true) return null;
183
+ continue;
184
+ }
185
+ if (assigned) return null;
186
+ if (flags.has(arg)) continue;
187
+ if (/^-[A-Za-z0-9]{2,}$/.test(arg) && [...arg.slice(1)].every((letter) => flags.has(`-${letter}`))) continue;
188
+ return null;
189
+ }
190
+ return words;
191
+ }
192
+
193
+ const reads = (spec) => (args, project) => readArguments(args, spec, project) !== null;
194
+
195
+ const GIT_LOOK = {
196
+ flags: [
197
+ ...letters("pqswbinlcrtEF"), "--stat", "--shortstat", "--numstat", "--name-only", "--name-status", "--oneline", "--cached", "--staged",
198
+ "--patch", "--no-patch", "--graph", "--all", "--decorate", "--follow", "--no-color", "--abbrev-commit", "--word-diff", "--reverse",
199
+ "--merges", "--no-merges", "--first-parent", "--short", "--branch", "--porcelain", "--long", "--tags", "--always", "--show-toplevel",
200
+ "--abbrev-ref", "--verify", "--count", "--others", "--modified", "--deleted", "--exclude-standard", "--summary", "--root",
201
+ ],
202
+ // `%G` in a format makes git run gpg on each commit.
203
+ valued: { "-n": number, "-U": number, "--max-count": number, "--abbrev": number, "--format": (value) => !value.includes("%G"),
204
+ "--pretty": (value) => !value.includes("%G"), "--since": any,
205
+ "--until": any, "--author": any, "--grep": any, "--diff-filter": any, "-L": any, "-e": any, "-S": any, "-G": any },
206
+ count: true,
207
+ anywhere: true,
208
+ };
209
+ /** `git grep` reads `-n` as "show line numbers", where `git log` reads it as a count. */
210
+ const GIT_GREP = {
211
+ flags: [...letters("nilcwhIEF"), "--line-number", "--ignore-case", "--files-with-matches", "--count", "--word-regexp", "--fixed-strings",
212
+ "--extended-regexp", "--cached", "--no-color"],
213
+ valued: { "-e": any, "-A": number, "-B": number, "-C": number },
214
+ anywhere: true,
215
+ };
216
+ const GIT_VERBS = new Set([
217
+ "status", "diff", "log", "show", "blame", "ls-files", "ls-tree", "grep", "rev-parse", "rev-list", "describe", "shortlog", "merge-base",
218
+ ]);
219
+
220
+ /**
221
+ * Test runners and checkers take files to check and a few options that change
222
+ * only what is printed. A plain word must look like a path, because these
223
+ * tools read a bare word as a subcommand, or as the value of the flag before it.
224
+ */
225
+ const TOOLS = {
226
+ tsc: { flags: ["--noEmit", "--pretty", "--skipLibCheck", "--strict"], valued: { "-p": any, "--project": any }, needs: ["--noEmit"], files: false },
227
+ "vue-tsc": { flags: ["--noEmit", "--pretty", "--skipLibCheck"], valued: { "-p": any, "--project": any }, needs: ["--noEmit"], files: false },
228
+ vitest: { flags: ["--run", "--silent", "--passWithNoTests"], valued: { "--bail": number, "-t": any }, starts: ["run"], needs: ["run", "--run"] },
229
+ jest: { flags: ["--ci", "--runInBand", "-i", "--silent", "--verbose", "--bail", "--passWithNoTests"], valued: { "-t": any } },
230
+ mocha: { flags: ["--bail", "-b", "--exit"], valued: { "-g": any, "--grep": any } },
231
+ ava: { flags: ["--fail-fast", "--serial", "-s", "--verbose", "-v"], valued: { "-m": any, "--match": any } },
232
+ // Without one of these, prettier rewrites the files it is given.
233
+ prettier: { flags: ["--check", "-c", "--list-different", "-l"], needs: ["--check", "-c", "--list-different", "-l"] },
234
+ eslint: { flags: ["--quiet", "--no-color"], valued: { "--max-warnings": number } },
235
+ biome: { flags: [], starts: ["check", "lint"], needs: ["check", "lint"] },
236
+ };
237
+
238
+ /** A word with a `/` or a `.` in it, so not a subcommand and not `true`, `false`, or `null`. */
239
+ const looksLikePath = (word) => /[/.]/.test(word) && !/^(?:true|false|null|undefined)$/i.test(word);
240
+
241
+ function toolAllowed(tool, args, project) {
242
+ const spec = TOOLS[tool];
243
+ const words = readArguments(args, spec, project);
244
+ if (!words || !words.every(unremarkable)) return false;
245
+ if (spec.needs && !spec.needs.some((word) => args.includes(word))) return false;
246
+ const files = spec.starts?.includes(words[0]) ? words.slice(1) : words;
247
+ return spec.files === false ? files.length === 0 : files.every(looksLikePath);
248
+ }
249
+
250
+ const PYTEST = { flags: ["-q", "-v", "-vv", "-x", "-s", "--lf", "--ff", "-ra"],
251
+ valued: { "-k": any, "-m": any, "--maxfail": number, "--tb": (value) => /^(?:auto|long|short|line|native|no)$/.test(value) } };
252
+ const pytestAllowed = (args, project) => readArguments(args, PYTEST, project)?.every((word) => unremarkable(word) && looksLikePath(word)) === true;
253
+
254
+ /** Test files to run, given after `--` or on their own. An option would go to the runner unread. */
255
+ function testFiles(args, project) {
256
+ const files = args[0] === "--" ? args.slice(1) : args;
257
+ return files.every((file) => !file.startsWith("-") && looksLikePath(file) && staysInProject(file, project) && unremarkable(file));
258
+ }
259
+
260
+ /** A script and the plain words it is given. An option would go to the script unread. */
261
+ function scriptAndWords(words, pattern, project) {
262
+ return typeof words[0] === "string" && pattern.test(words[0])
263
+ && words.every((word) => !word.startsWith("-") && staysInProject(word, project) && unremarkable(word));
264
+ }
265
+
266
+ function nodeAllowed(args, project) {
267
+ if (args.length === 1 && /^(?:--version|-v)$/.test(args[0])) return true;
268
+ let index = 0;
269
+ let testing = false;
270
+ // Only options that carry their own value, so none can take the script's place.
271
+ for (; index < args.length && args[index].startsWith("-"); index += 1) {
272
+ if (args[index] === "--import" && /^tsx(?:\/esm)?$/.test(args[index + 1] ?? "")) index += 1;
273
+ 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;
275
+ }
276
+ const rest = args.slice(index);
277
+ // Without a script, node reads its program from the input, unless it was told to find the tests.
278
+ return rest.length === 0 ? testing : scriptAndWords(rest, SOURCE_FILE, project);
279
+ }
280
+
281
+ function pythonAllowed(args, project) {
282
+ return args[0] === "-m" ? args[1] === "pytest" && pytestAllowed(args.slice(2), project) : scriptAndWords(args, PYTHON_FILE, project);
283
+ }
284
+
285
+ /**
286
+ * Each program these rules can read, and what it may be given. A program that
287
+ * is not listed is not allowed, however harmless it is, and neither is an
288
+ * option that is not listed for it.
289
+ */
290
+ const PROGRAMS = {
291
+ ls: reads({ flags: letters("1AaFRSdhlrt") }),
292
+ pwd: (args) => args.length === 0,
293
+ cat: reads({ flags: ["-n", "-b"] }),
294
+ head: reads({ valued: { "-n": number, "-c": number }, count: true }),
295
+ tail: reads({ valued: { "-n": number, "-c": number }, count: true }),
296
+ wc: reads({ flags: letters("lwcm") }),
297
+ stat: reads({}),
298
+ du: reads({ flags: letters("shac"), valued: { "-d": number } }),
299
+ basename: reads({}),
300
+ dirname: reads({}),
301
+ cut: reads({ valued: { "-d": any, "-f": any, "-c": any } }),
302
+ date: (args) => args.every((arg) => /^\+[A-Za-z0-9%:_./ -]*$/.test(arg)),
303
+ echo: (args) => args.every((arg) => !arg.startsWith("-") || /^-[neE]+$/.test(arg)),
304
+ which: reads({}),
305
+ diff: reads({ flags: letters("urqwbBN"), valued: { "-U": number } }),
306
+ // A second file name is where `uniq` writes.
307
+ uniq: (args, project) => (readArguments(args, { flags: letters("cdui") }, project) ?? [0, 0]).length <= 1,
308
+ sort: reads({ flags: letters("bdfgnruV"), valued: { "-k": any, "-t": any } }),
309
+ tree: reads({ flags: ["-a", "-d"], valued: { "-L": number, "-I": any } }),
310
+ // An option after the pattern is read as an option or as a file to read. Either only reads.
311
+ grep: reads({ flags: letters("EFGHIRabchilnoqrsvwx"), count: true, anywhere: true, valued: { "-e": any, "-A": number, "-B": number, "-C": number,
312
+ "-m": number, "--include": any, "--exclude": any, "--exclude-dir": any, "--color": any } }),
313
+ rg: reads({ flags: [...letters("FHINSabchilnosuvwx"), "--files", "--hidden", "--no-ignore", "--no-heading", "--line-number"],
314
+ anywhere: true, valued: { "-e": any, "-g": any, "-t": name, "-T": name, "-A": number, "-B": number, "-C": number, "-m": number } }),
315
+ find: (args, project) => {
316
+ let index = 0;
317
+ while (index < args.length && !args[index].startsWith("-")) index += 1;
318
+ if (!args.slice(0, index).every((path) => staysInProject(path, project))) return false;
319
+ // After the places to look, only tests that choose files. Nothing that acts on them.
320
+ const tests = { "-name": 1, "-iname": 1, "-path": 1, "-ipath": 1, "-type": 1, "-maxdepth": 1, "-mindepth": 1, "-newer": 1, "-mtime": 1,
321
+ "-mmin": 1, "-size": 1, "-not": 0, "-o": 0, "-a": 0, "-print": 0, "-prune": 0, "-empty": 0 };
322
+ while (index < args.length) {
323
+ if (!Object.hasOwn(tests, args[index])) return false;
324
+ const takes = tests[args[index]];
325
+ if (takes === 1 && (args[index + 1] === undefined || !staysInProject(args[index + 1], project))) return false;
326
+ index += 1 + takes;
327
+ }
328
+ return true;
329
+ },
330
+ git: ([verb, ...args], project) => {
331
+ if (verb === "branch") return args.every((arg) => /^(?:--show-current|--list|--all|--remotes|-a|-r|-l|-v|-vv)$/.test(arg));
332
+ if (verb === "tag") return args.every((arg) => /^(?:--list|-l)$/.test(arg));
333
+ // One `--` may separate revisions from paths. What follows it is a path.
334
+ const separator = args.indexOf("--");
335
+ const before = separator < 0 ? args : args.slice(0, separator);
336
+ const paths = separator < 0 ? [] : args.slice(separator + 1);
337
+ return GIT_VERBS.has(verb) && readArguments(before, verb === "grep" ? GIT_GREP : GIT_LOOK, project) !== null
338
+ && paths.every((path) => !path.startsWith("-") && staysInProject(path, project));
339
+ },
340
+ npm: ([verb, ...rest], project) => {
341
+ if (verb === "ls" || verb === "list") return rest.every((arg) => /^(?:--depth=\d+|--all|-a|--json|--long|-l)$/.test(arg));
342
+ // `npm run` runs a script from the project's manifest and nothing else.
343
+ if (verb === "run" || verb === "run-script") return rest.length === 1 && plainName(rest[0]);
344
+ return verb === "test" && testFiles(rest, project);
345
+ },
346
+ pnpm: ([verb, ...rest], project) => verb === "test" && testFiles(rest, project),
347
+ yarn: ([verb, ...rest], project) => verb === "test" && testFiles(rest, project),
348
+ bun: ([verb, ...rest], project) => verb === "test" && testFiles(rest, project),
349
+ // Without one of these, a tool the project lacks is downloaded and run.
350
+ npx: ([first, tool, ...rest], project) => /^(?:--no-install|--no|--offline)$/.test(first ?? "")
351
+ && Object.hasOwn(TOOLS, tool ?? "") && toolAllowed(tool, rest, project),
352
+ ...Object.fromEntries(Object.keys(TOOLS).map((tool) => [tool, (args, project) => toolAllowed(tool, args, project)])),
353
+ pytest: pytestAllowed,
354
+ node: nodeAllowed,
355
+ python: pythonAllowed,
356
+ python3: pythonAllowed,
357
+ go: ([verb, ...args], project) => verb === "version" && args.length === 0
358
+ || (verb === "test" || verb === "vet")
359
+ && readArguments(args, { flags: ["-v", "-race", "-short", "-failfast"], dashed: true,
360
+ valued: { "-run": any, "-count": number, "-timeout": (value) => /^\d{1,4}[smh]$/.test(value) } }, project)
361
+ ?.every((word) => /^[A-Za-z0-9_./-]+$/.test(word) && unremarkable(word)) === true,
362
+ cargo: ([verb, ...args], project) => verb === "fmt" ? args.length === 1 && args[0] === "--check"
363
+ : /^(?:test|check|clippy)$/.test(verb ?? "")
364
+ && readArguments(args, { flags: ["--all", "--workspace", "--all-features", "--all-targets", "--release", "-q", "--quiet", "--lib",
365
+ "--tests", "--locked", "--offline"], valued: { "-p": name, "--package": name } }, project)?.every(plainName) === true,
366
+ // A target only. `-f`, `-C`, and `NAME=value` choose what runs, and `-p` prints the environment.
367
+ make: (args) => args.length > 0 && args.every(plainName),
368
+ };
369
+
370
+ /**
371
+ * Programs that run code. Any of them may run what arrives on its input: an
372
+ * interpreter given no file, or a project script that is only an interpreter.
373
+ */
374
+ const RUNS_CODE = new Set([
375
+ "node", "python", "python3", "npm", "pnpm", "yarn", "bun", "npx", "make", "pytest", "go", "cargo", ...Object.keys(TOOLS),
376
+ ]);
377
+
378
+ function simpleCommandAllowed({ words, fed }, project) {
379
+ const [program, ...rest] = words;
380
+ // The program is named in plain lower case: no path, no quotes, no assignment before it.
381
+ if (program.quoted || !Object.hasOwn(PROGRAMS, program.value)) return false;
382
+ if (fed && RUNS_CODE.has(program.value)) return false;
383
+ return PROGRAMS[program.value](rest.map((word) => word.value), project) === true;
384
+ }
385
+
386
+ /**
387
+ * True when a person must decide. It means only that these rules cannot read
388
+ * the command, not that the command is harmful.
389
+ */
390
+ export function commandNeedsPerson(command, project) {
391
+ try {
392
+ if (!isText(command, PERMISSION_REVIEW_MAX_COMMAND_CHARS)) return true;
393
+ const root = normalizedProject(project);
394
+ if (!root) return true;
395
+ const commands = readCommands(command);
396
+ return !commands || !commands.every((simple) => simpleCommandAllowed(simple, root));
397
+ } catch {
398
+ return true;
399
+ }
400
+ }
401
+
402
+ const KINDS = ["read", "check", "edit", "risky"];
403
+
404
+ const QUESTIONS = {
405
+ kind: {
406
+ type: "choice",
407
+ instructions: "What is the most consequential thing `commands` does?",
408
+ criteria: {
409
+ read: "Only reads, lists, or searches files or version history. Changes nothing.",
410
+ check: "Runs the project's own tests, type checker, linter, or build.",
411
+ edit: "Creates, changes, moves, or stages files inside `project`.",
412
+ risky: "Deletes or empties files, runs a script whose effect cannot be told from its name, affects anything outside `project`, contacts a network service, or handles money, customers, production, or credentials.",
413
+ },
414
+ },
415
+ };
416
+
417
+ /**
418
+ * Build the state and question for one pending request, or null when the
419
+ * rules already require a person.
420
+ */
421
+ export function buildPermissionReviewRequest(input) {
422
+ try {
423
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
424
+ const project = normalizedProject(input.project);
425
+ if (!project || !Array.isArray(input.commands)) return null;
426
+ // Copy once, then judge the copy: what is checked is what is sent.
427
+ const commands = Array.from(input.commands);
428
+ if (commands.length === 0 || commands.length > PERMISSION_REVIEW_MAX_COMMANDS
429
+ || commands.some((command) => commandNeedsPerson(command, project))) return null;
430
+ return { state: { commands, project }, questions: structuredClone(QUESTIONS) };
431
+ } catch {
432
+ return null;
433
+ }
434
+ }
435
+
436
+ function own(value, key) {
437
+ return value && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, key) ? value[key] : undefined;
438
+ }
439
+
440
+ function probability(value) {
441
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1 ? value : null;
442
+ }
443
+
444
+ function wholeKinds(kinds) {
445
+ if (!kinds || typeof kinds !== "object" || Array.isArray(kinds)) return null;
446
+ const scores = {};
447
+ let total = 0;
448
+ for (const kind of KINDS) {
449
+ const value = probability(own(kinds, kind));
450
+ if (value === null) return null;
451
+ scores[kind] = value;
452
+ total += value;
453
+ }
454
+ // A kind this file does not know, or scores that are not a whole, is not an answer.
455
+ return Reflect.ownKeys(kinds).every((kind) => KINDS.includes(kind)) && Math.abs(total - 1) <= KIND_TOTAL_TOLERANCE ? scores : null;
456
+ }
457
+
458
+ /** Read the answer. The kind the model picked must be the kind it scored highest. */
459
+ export function parsePermissionReviewAnswers(body) {
460
+ try {
461
+ const kind = own(own(body, "answers"), "kind");
462
+ const kinds = wholeKinds(own(kind, "probabilities"));
463
+ const choice = own(kind, "choice");
464
+ return { kinds: kinds && KINDS.includes(choice) && KINDS.every((other) => kinds[other] <= kinds[choice]) ? kinds : null };
465
+ } catch {
466
+ return { kinds: null };
467
+ }
468
+ }
469
+
470
+ /**
471
+ * "allow" only for a confident reading or checking command. Anything else,
472
+ * including a command that changes files and a missing answer, is "ask".
473
+ */
474
+ export function decidePermissionReview(answers) {
475
+ try {
476
+ const kinds = wholeKinds(own(answers, "kinds"));
477
+ return kinds !== null && kinds.read + kinds.check >= PERMISSION_REVIEW_APPROVE_AT
478
+ && kinds.risky <= PERMISSION_REVIEW_RISKY_AT_MOST ? "allow" : "ask";
479
+ } catch {
480
+ return "ask";
481
+ }
482
+ }
@@ -0,0 +1,15 @@
1
+ import type { NativeRuntimeDeath } from '../apps/desktop/shared/execution-protocol.js';
2
+
3
+ type Identity = Readonly<{ provider: string; workAttemptId: string; roomId: string;
4
+ entryId: string; executionGenerationId: string; continuationId: string | null }>;
5
+ type Connection = { kind: 'claude_cli'; pid: number | null; processIdentity?: string | null };
6
+ type Terminal = { nativeRuntimeDeath?: NativeRuntimeDeath; endedAt: string; exitCode: number | null;
7
+ signal: string | null; terminalCause: 'exited' | 'killed' | 'stopped' | 'crashed' | 'protocol_error' | 'provider_quota';
8
+ providerContinuationId: string | null };
9
+ export function providerAcquisitionIdentity(provider: string,
10
+ request: { workAttemptId: string; roomId: string; supervisorEntryId?: string; supervisorExecutionGenerationId?: string },
11
+ continuationId?: string | null): Identity | null;
12
+ export function retainProviderAcquisitionEvidence(error: unknown, identity: Identity | null,
13
+ connection: Connection, terminal: Terminal): void;
14
+ export function providerAcquisitionEvidence(error: unknown, expected: Identity | null):
15
+ Readonly<{ identity: Identity; connection: Readonly<Connection>; terminal: Readonly<Terminal> }> | undefined;
@@ -0,0 +1,33 @@
1
+ // In-process evidence owned by an adapter that rejected a native acquisition.
2
+ // Keep the original error/diagnostic and do not serialize an operational receipt
3
+ // through provider-authored text, IPC, or a returned successful handle.
4
+ const failures = new WeakMap();
5
+
6
+ export function providerAcquisitionIdentity(provider, request, continuationId = null) {
7
+ const identity = { provider, workAttemptId: request.workAttemptId, roomId: request.roomId,
8
+ entryId: request.supervisorEntryId, executionGenerationId: request.supervisorExecutionGenerationId,
9
+ continuationId };
10
+ if (Object.entries(identity).some(([key, value]) => key !== 'continuationId'
11
+ && (typeof value !== 'string' || !value))) return null;
12
+ return Object.freeze(identity);
13
+ }
14
+
15
+ export function retainProviderAcquisitionEvidence(error, identity, connection, terminal) {
16
+ if (!(error instanceof Error) || !identity || !terminal.nativeRuntimeDeath) return;
17
+ // One immutable receipt per rejection. A reused error must never be retargeted.
18
+ if (failures.has(error)) throw new Error('Failed provider acquisition already has evidence.');
19
+ failures.set(error, Object.freeze({ identity,
20
+ connection: Object.freeze({ ...connection }),
21
+ terminal: Object.freeze({ ...terminal,
22
+ nativeRuntimeDeath: Object.freeze({ ...terminal.nativeRuntimeDeath }) }) }));
23
+ }
24
+
25
+ export function providerAcquisitionEvidence(error, expected) {
26
+ const evidence = error instanceof Error ? failures.get(error) : undefined;
27
+ if (!evidence) return undefined;
28
+ if (!expected || Object.keys(evidence.identity).some(key => evidence.identity[key] !== expected[key])
29
+ || (expected.continuationId !== null && evidence.terminal.providerContinuationId !== expected.continuationId)) {
30
+ throw new Error('Failed provider acquisition evidence does not match the active launch.');
31
+ }
32
+ return evidence;
33
+ }
@@ -30,5 +30,6 @@ export type RoomAgentWorkPollResponse = { room_id: string; cursor: string } & (
30
30
  | { changed: false; snapshot: null }
31
31
  );
32
32
  export function isClearedRoomAgentWorkSummary(value: unknown): value is ClearedRoomAgentWorkSummary;
33
+ export function hasReviewableRoomContribution(summary: RoomAgentWorkSummary | ClearedRoomAgentWorkSummary | null | undefined): boolean;
33
34
  /** Return a canonical allowlisted copy, or reject without echoing private input. */
34
35
  export function parseRoomAgentWorkSummary(value: unknown): RoomAgentWorkSummary | null;
@@ -20,6 +20,17 @@ export function isClearedRoomAgentWorkSummary(value) {
20
20
  return exactKeys(value, ["version", "availability"]) && value.version === 1 && value.availability === "cleared";
21
21
  }
22
22
 
23
+ /** Review eligibility is separate from capture/execution evidence. An unknown
24
+ * turn may offer a real cumulative review, but a known empty turn cannot claim
25
+ * earlier work. File metadata matters even when textual line totals are zero. */
26
+ export function hasReviewableRoomContribution(summary) {
27
+ const changes = summary?.contribution?.changes;
28
+ if (!changes) return false;
29
+ const snapshot = changes.state === 'ready' ? changes : summary.workspace;
30
+ return snapshot?.state === 'ready' && (snapshot.files.length > 0 || snapshot.hidden_files > 0
31
+ || snapshot.additions > 0 || snapshot.deletions > 0 || snapshot.patch.length > 0 || snapshot.patch_truncated);
32
+ }
33
+
23
34
  /** Return a canonical allowlisted copy, or reject without echoing private input. */
24
35
  export function parseRoomAgentWorkSummary(value) {
25
36
  const workspace = [2, 3].includes(value?.version) ? parseWorkspaceChangeSummary(value.workspace) : null;
@@ -3,11 +3,14 @@ export const ROOM_RESOURCE_AGENT_WORK: "agent_work";
3
3
  /** Content-free approval-state hint; consumers repair through separately authorized exact reads. */
4
4
  export const ROOM_RESOURCE_AGENT_APPROVAL: "agent_approval";
5
5
  export const ROOM_RESOURCE_EXECUTION_DELEGATION: "execution_delegation";
6
+ /** What agents are waiting for changed; consumers re-read the room's wake rules. */
7
+ export const ROOM_RESOURCE_WAKE_RULES: "wake_rules";
6
8
  /** Protocol-known references; consumers independently choose what they render. */
7
9
  export const ROOM_RESOURCE_INVALIDATION_RESOURCES: readonly [
8
10
  "agent_work",
9
11
  "agent_approval",
10
12
  "execution_delegation",
13
+ "wake_rules",
11
14
  ];
12
15
 
13
16
  export type RoomResourceInvalidationResource =
@@ -5,12 +5,14 @@ export const ROOM_RESOURCE_AGENT_WORK = "agent_work";
5
5
  // authorized exact reads; the pointer never carries approval content.
6
6
  export const ROOM_RESOURCE_AGENT_APPROVAL = "agent_approval";
7
7
  export const ROOM_RESOURCE_EXECUTION_DELEGATION = "execution_delegation";
8
+ export const ROOM_RESOURCE_WAKE_RULES = "wake_rules";
8
9
  // Protocol-known references. Each consumer still decides which surfaces, if
9
10
  // any, react to a supported pointer.
10
11
  export const ROOM_RESOURCE_INVALIDATION_RESOURCES = [
11
12
  ROOM_RESOURCE_AGENT_WORK,
12
13
  ROOM_RESOURCE_AGENT_APPROVAL,
13
14
  ROOM_RESOURCE_EXECUTION_DELEGATION,
15
+ ROOM_RESOURCE_WAKE_RULES,
14
16
  ];
15
17
 
16
18
  function exactKeys(value, keys) {
@@ -0,0 +1,48 @@
1
+ export type GitHubRoomChatEventKind =
2
+ | "pull_request"
3
+ | "review"
4
+ | "comment"
5
+ | "issue"
6
+ | "check_failed"
7
+ | "repository";
8
+
9
+ export declare const GITHUB_ROOM_CHAT_EVENT_KINDS: readonly GitHubRoomChatEventKind[];
10
+ export declare const ROOM_AGENT_GUIDELINES_TOKEN_BUDGET: number;
11
+ export declare const ROOM_AGENT_GUIDELINES_BYTES_PER_TOKEN: number;
12
+ export declare const ROOM_AGENT_GUIDELINES_MAX_BYTES: number;
13
+ export declare const ROOM_AGENT_GUIDELINES_NOTE: string;
14
+
15
+ export interface GitHubRoomChatEventFilter {
16
+ room_id: string;
17
+ /** The kinds posted to the room as messages, in canonical order. */
18
+ enabled_kinds: GitHubRoomChatEventKind[];
19
+ all_kinds: GitHubRoomChatEventKind[];
20
+ /** The room these kinds were read from when this room has none of its own. */
21
+ inherited_from_room_id: string | null;
22
+ can_manage: boolean;
23
+ }
24
+
25
+ export interface RoomAgentGuidelines {
26
+ room_id: string;
27
+ guidelines: string | null;
28
+ updated_by: string | null;
29
+ updated_at: string | null;
30
+ /** The room these guidelines were read from when this room has none of its own. */
31
+ inherited_from_room_id: string | null;
32
+ max_bytes: number;
33
+ token_budget: number;
34
+ note: string;
35
+ can_manage: boolean;
36
+ }
37
+
38
+ /** The text as stored: `\n` line endings, no control characters, trimmed. Not a string gives "". */
39
+ export declare function normalizeRoomAgentGuidelines(value: unknown): string;
40
+ /** Whether the text has the marks of a binary file rather than of writing. */
41
+ export declare function looksLikeBinaryText(value: unknown): boolean;
42
+ /** The size the limit is enforced on: UTF-8 bytes of the normalized text. */
43
+ export declare function roomAgentGuidelinesBytes(value: unknown): number;
44
+ export declare function estimateRoomAgentGuidelineTokens(value: unknown): number;
45
+ /** The enabled kinds in canonical order, or null when the input is invalid. */
46
+ export declare function normalizeGitHubRoomChatEventKinds(value: unknown): GitHubRoomChatEventKind[] | null;
47
+ /** The kind a repository event is filed under, or null when it has none. */
48
+ export declare function githubRoomChatEventKind(event: { kind?: string } | null | undefined): GitHubRoomChatEventKind | null;