letagents 0.12.29 → 0.12.31

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 (37) hide show
  1. package/README.md +1 -1
  2. package/dist/mcp/local-state/storage.js +0 -5
  3. package/dist/mcp/server/runtime/api.js +53 -1
  4. package/dist/mcp/server/runtime/identity/liveness.js +19 -1
  5. package/dist/mcp/server/runtime/process-connection.js +239 -0
  6. package/dist/mcp/server/runtime/worker-bearer.js +0 -4
  7. package/dist/mcp/server/runtime/worker-handles.js +109 -15
  8. package/dist/mcp/server/runtime-contract.js +11 -0
  9. package/dist/mcp/server/runtime.js +1 -1
  10. package/dist/mcp/server/supervised-tool-facade.js +1 -1
  11. package/dist/mcp/server/tools/agent-sessions.js +38 -6
  12. package/dist/mcp/server/tools/knowledge.js +29 -0
  13. package/dist/mcp/server/tools/rooms/inspection-tools.js +1 -1
  14. package/dist/mcp/server.js +21 -9
  15. package/dist/mcp/worker-call-context.js +4 -0
  16. package/dist/shared/agent-presence.js +141 -0
  17. package/dist/shared/codenames.js +2 -104
  18. package/dist/shared/handoff.js +0 -6
  19. package/dist/shared/request-headers.js +11 -0
  20. package/dist/shared/room-agent-prompts.js +1 -1
  21. package/package.json +4 -5
  22. package/shared/activation-routing.d.mts +5 -0
  23. package/shared/activation-routing.mjs +10 -1
  24. package/shared/agent-codenames.d.mts +17 -0
  25. package/shared/agent-codenames.mjs +96 -0
  26. package/shared/daemon-maintenance.d.mts +13 -0
  27. package/shared/daemon-maintenance.mjs +99 -0
  28. package/shared/permission-review.d.mts +30 -0
  29. package/shared/permission-review.mjs +482 -0
  30. package/shared/provider-acquisition-evidence.d.mts +15 -0
  31. package/shared/provider-acquisition-evidence.mjs +33 -0
  32. package/shared/room-agent-work.d.mts +1 -0
  33. package/shared/room-agent-work.mjs +11 -0
  34. package/shared/room-settings.d.mts +48 -0
  35. package/shared/room-settings.mjs +108 -0
  36. package/shared/ui/PrivateMessages.vue +12 -17
  37. package/shared/ui/private-messages.css +35 -25
@@ -0,0 +1,96 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { normalizeRoutingHandle, normalizeRoutingSender } from "./routing-aliases.mjs";
4
+
5
+ /**
6
+ * The one pool of generated agent names. Every runtime that names an agent
7
+ * (the API, MCP, the desktop app and its daemon) draws from this list, so a
8
+ * name means the same thing wherever it was assigned.
9
+ *
10
+ * The order is part of the contract: a legacy MCP identity with no saved name
11
+ * re-derives its name, and therefore its key, from an index into this list.
12
+ * Append new words; never reorder or remove.
13
+ */
14
+ export const AGENT_CODENAMES = Object.freeze([
15
+ "amber", "anchor", "autumn", "badger", "bay", "bear", "brook", "calm",
16
+ "canyon", "cedar", "clear", "cloud", "comet", "copper", "creek", "crisp",
17
+ "crest", "dawn", "delta", "dune", "ember", "falcon", "fern", "field",
18
+ "firefly", "fjord", "forest", "fox", "garden", "glade", "golden", "granite",
19
+ "grove", "harbor", "hawk", "hollow", "indigo", "ivory", "jade", "juniper",
20
+ "lagoon", "lake", "lantern", "leaf", "lively", "lunar", "lynx", "maple",
21
+ "marsh", "meadow", "mesa", "misty", "moon", "morrow", "moss", "noble",
22
+ "oak", "olive", "opal", "otter", "owl", "peak", "pearl", "pine",
23
+ "quiet", "raven", "reef", "ridge", "river", "rook", "sage", "scarlet",
24
+ "shore", "silver", "sky", "solar", "sparrow", "spring", "star", "stone",
25
+ "storm", "summit", "sun", "sunlit", "swift", "thicket", "tidal", "timber",
26
+ "trail", "valley", "verdant", "vista", "warm", "wave", "west", "wild",
27
+ "willow", "wind", "winter", "wolf", "wood", "wren",
28
+ ]);
29
+
30
+ export const AGENT_CODENAME_SPACE = AGENT_CODENAMES.length * AGENT_CODENAMES.length;
31
+
32
+ function titleCase(word) {
33
+ return word.charAt(0).toUpperCase() + word.slice(1);
34
+ }
35
+
36
+ /** Fused two-word codename at `index`, e.g. "RiverValley". Any integer wraps into the pool. */
37
+ export function agentCodenameAt(index) {
38
+ const normalized = ((index % AGENT_CODENAME_SPACE) + AGENT_CODENAME_SPACE) % AGENT_CODENAME_SPACE;
39
+ const first = AGENT_CODENAMES[Math.floor(normalized / AGENT_CODENAMES.length)];
40
+ const second = AGENT_CODENAMES[normalized % AGENT_CODENAMES.length];
41
+ return `${titleCase(first)}${titleCase(second)}`;
42
+ }
43
+
44
+ export function agentCodenameSeedIndex(seed) {
45
+ return createHash("sha256").update(String(seed)).digest().readUInt32BE(0) % AGENT_CODENAME_SPACE;
46
+ }
47
+
48
+ /**
49
+ * The form in which two display names are the same name to a room. A display
50
+ * name is what people and agents type after "@", so names are compared the way
51
+ * mention routing compares them: "FieldMeadow", "fieldmeadow" and
52
+ * "Field Meadow" all answer to "@FieldMeadow" and cannot coexist.
53
+ */
54
+ export function agentDisplayNameKey(displayName) {
55
+ return normalizeRoutingHandle(displayName) || normalizeRoutingSender(displayName);
56
+ }
57
+
58
+ /**
59
+ * A codename no listed name already answers to. The seed fixes where the scan
60
+ * starts, so one agent is offered the same name whenever the same names are
61
+ * held. Returns null only when the whole pool is held.
62
+ */
63
+ export function suggestFreeAgentCodename(heldDisplayNames, seed) {
64
+ const held = new Set();
65
+ for (const name of heldDisplayNames ?? []) {
66
+ const key = agentDisplayNameKey(typeof name === "string" ? name : "");
67
+ if (key) held.add(key);
68
+ }
69
+ const start = agentCodenameSeedIndex(seed);
70
+ for (let offset = 0; offset < AGENT_CODENAME_SPACE; offset += 1) {
71
+ const candidate = agentCodenameAt(start + offset);
72
+ if (!held.has(agentDisplayNameKey(candidate))) return candidate;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ const PLACEHOLDER_SUFFIX = " supervised agent";
78
+ const PLACEHOLDER_PROVIDERS = new Set([
79
+ "codex", "claude", "claude-code", "claude code", "antigravity", "cursor",
80
+ "open-model", "open_model", "open model",
81
+ ]);
82
+
83
+ /**
84
+ * Whether a name is what was written when no name was chosen: nothing,
85
+ * "Supervised agent", or a provider followed by "supervised agent". Only
86
+ * known providers count, so a name a person chose that happens to end the
87
+ * same way is left alone.
88
+ */
89
+ export function isPlaceholderAgentDisplayName(displayName, provider) {
90
+ const name = String(displayName ?? "").trim().toLowerCase().replace(/\s+/g, " ");
91
+ if (!name || name === PLACEHOLDER_SUFFIX.trim()) return true;
92
+ if (!name.endsWith(PLACEHOLDER_SUFFIX)) return false;
93
+ const label = name.slice(0, -PLACEHOLDER_SUFFIX.length);
94
+ return PLACEHOLDER_PROVIDERS.has(label)
95
+ || label === String(provider ?? "").trim().toLowerCase();
96
+ }
@@ -0,0 +1,13 @@
1
+ export interface DaemonMaintenanceHold { version: 1; id: string; createdAt: string }
2
+ export function daemonMaintenancePath(root?: string): string;
3
+ export const DAEMON_MAINTENANCE_MESSAGE: string;
4
+ export function readDaemonMaintenance(path?: string): Promise<DaemonMaintenanceHold | null>;
5
+ export function createDaemonMaintenance(path?: string): Promise<DaemonMaintenanceHold>;
6
+ export function clearDaemonMaintenance(id: string, path?: string): Promise<void>;
7
+
8
+ export interface DaemonMaintenanceOperation {
9
+ read(): Promise<DaemonMaintenanceHold | null>;
10
+ create(): Promise<DaemonMaintenanceHold>;
11
+ clear(id: string): Promise<void>;
12
+ }
13
+ export function withDaemonMaintenanceOperation<T>(operation: (owner: DaemonMaintenanceOperation) => Promise<T>, path?: string): Promise<T>;
@@ -0,0 +1,99 @@
1
+ import { constants } from "node:fs";
2
+ import { link, lstat, mkdir, open, unlink } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { homedir } from "node:os";
6
+
7
+ export const daemonMaintenancePath = (root = join(homedir(), ".letagents")) => join(root, "daemon-maintenance.json");
8
+ export const DAEMON_MAINTENANCE_MESSAGE = "Agent supervision is paused for service maintenance. Resume it in Settings → Updates.";
9
+
10
+ async function syncDirectory(path) {
11
+ const directory = await open(dirname(path), "r");
12
+ try { await directory.sync(); } finally { await directory.close(); }
13
+ }
14
+
15
+ /** Only ENOENT means normal operation. Corruption or an unsafe path must fail closed. */
16
+ export async function readDaemonMaintenance(path = daemonMaintenancePath()) {
17
+ let file;
18
+ try { file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
19
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
20
+ try {
21
+ const stat = await file.stat();
22
+ if (!stat.isFile() || stat.size > 4096 || (stat.mode & 0o077) !== 0
23
+ || (process.getuid && stat.uid !== process.getuid())) throw new Error("Unsafe service maintenance record.");
24
+ const value = JSON.parse(await file.readFile("utf8"));
25
+ if (value?.version !== 1 || typeof value.id !== "string" || !/^[a-f0-9-]{36}$/.test(value.id)
26
+ || typeof value.createdAt !== "string" || !Number.isFinite(Date.parse(value.createdAt))
27
+ || Object.keys(value).sort().join(",") !== "createdAt,id,version") throw new Error("Invalid service maintenance record.");
28
+ return value;
29
+ } finally { await file.close(); }
30
+ }
31
+
32
+ /** Publish a complete, fsynced hold before any daemon signal; never replace an existing hold. */
33
+ async function publishHold(path) {
34
+ const existing = await readDaemonMaintenance(path);
35
+ if (existing) { await syncDirectory(path); return existing; }
36
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
37
+ const value = { version: 1, id: randomUUID(), createdAt: new Date().toISOString() };
38
+ const temporary = `${path}.${value.id}.tmp`;
39
+ const file = await open(temporary, "wx", 0o600);
40
+ try {
41
+ await file.writeFile(`${JSON.stringify(value)}\n`, "utf8");
42
+ await file.sync();
43
+ try { await link(temporary, path); }
44
+ catch (error) { if (error.code !== "EEXIST") throw error; }
45
+ await syncDirectory(path);
46
+ const published = await readDaemonMaintenance(path);
47
+ if (!published) throw new Error("Service maintenance record disappeared before commit.");
48
+ return published;
49
+ } finally { await file.close(); await unlink(temporary); }
50
+ }
51
+
52
+ /** Explicit resume owns removal, after the held daemon has exited. */
53
+ async function removeHold(id, path) {
54
+ const before = await lstat(path);
55
+ const value = await readDaemonMaintenance(path);
56
+ const after = await lstat(path);
57
+ if (!value || value.id !== id || before.ino !== after.ino || before.dev !== after.dev) {
58
+ throw new Error("Service maintenance changed. Refresh before resuming.");
59
+ }
60
+ await unlink(path);
61
+ await syncDirectory(path);
62
+ }
63
+
64
+ // Keep this inode forever. Unlinking a flock file can create two owners. Darwin
65
+ // uses the kernel lock; non-Darwin tests model ownership in-process, like the
66
+ // daemon singleton. Product maintenance is currently macOS-only.
67
+ const simulatedClaims = new Set();
68
+ export async function withDaemonMaintenanceOperation(operation, path = daemonMaintenancePath()) {
69
+ const lockPath = `${resolve(path)}.lock`;
70
+ await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
71
+ const native = process.platform === "darwin";
72
+ if (!native && simulatedClaims.has(lockPath)) throw new Error("Another service maintenance action is in progress.");
73
+ if (!native) simulatedClaims.add(lockPath);
74
+ let lock;
75
+ let active = true;
76
+ const assertActive = () => { if (!active) throw new Error("Service maintenance action has ended."); };
77
+ try {
78
+ lock = await open(lockPath, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW
79
+ | constants.O_NONBLOCK | (native ? 0x20 /* Darwin O_EXLOCK */ : 0), 0o600);
80
+ const stat = await lock.stat();
81
+ if (!stat.isFile() || (stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid())) {
82
+ throw new Error("Unsafe service maintenance lock.");
83
+ }
84
+ return await operation({
85
+ read: () => { assertActive(); return readDaemonMaintenance(path); },
86
+ create: () => { assertActive(); return publishHold(path); },
87
+ clear: id => { assertActive(); return removeHold(id, path); },
88
+ });
89
+ } catch (error) {
90
+ if (["EAGAIN", "EWOULDBLOCK"].includes(error.code)) throw new Error("Another service maintenance action is in progress.");
91
+ throw error;
92
+ } finally {
93
+ active = false;
94
+ await lock?.close();
95
+ if (!native) simulatedClaims.delete(lockPath);
96
+ }
97
+ }
98
+ export const createDaemonMaintenance = (path = daemonMaintenancePath()) => withDaemonMaintenanceOperation(owner => owner.create(), path);
99
+ export const clearDaemonMaintenance = (id, path = daemonMaintenancePath()) => withDaemonMaintenanceOperation(owner => owner.clear(id), path);
@@ -0,0 +1,30 @@
1
+ export const PERMISSION_REVIEW_APPROVE_AT: number;
2
+ export const PERMISSION_REVIEW_RISKY_AT_MOST: number;
3
+ export const PERMISSION_REVIEW_MAX_COMMANDS: number;
4
+ export const PERMISSION_REVIEW_MAX_COMMAND_CHARS: number;
5
+ export const PERMISSION_REVIEW_MAX_PROJECT_CHARS: number;
6
+
7
+ export type PermissionReviewKind = "read" | "check" | "edit" | "risky";
8
+ export type PermissionReviewAnswers = {
9
+ /** Null when the model did not give one consistent answer. */
10
+ kinds: Record<PermissionReviewKind, number> | null;
11
+ };
12
+ export type PermissionReviewDecision = "allow" | "ask";
13
+
14
+ export type PermissionReviewRequest = {
15
+ state: { commands: string[]; project: string };
16
+ questions: Record<string, { type: "choice"; instructions: string; criteria: Record<string, string> }>;
17
+ };
18
+
19
+ /** True when a person must decide: no fixed rule can read the command in full. */
20
+ export function commandNeedsPerson(command: unknown, project: unknown): boolean;
21
+
22
+ /** Null when the fixed rules already require a person. */
23
+ export function buildPermissionReviewRequest(input: {
24
+ commands: readonly string[];
25
+ project: string;
26
+ }): PermissionReviewRequest | null;
27
+
28
+ export function parsePermissionReviewAnswers(body: unknown): PermissionReviewAnswers;
29
+
30
+ export function decidePermissionReview(answers: PermissionReviewAnswers | null | undefined): PermissionReviewDecision;