letagents 0.9.0 → 0.11.0

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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Agent codenames — extracted from server.ts per Emmy's directive.
3
+ *
4
+ * Each agent instance gets a two-word codename (e.g. "River Valley")
5
+ * deterministically derived from its runtime key via SHA-256 hashing.
6
+ */
7
+ import { createHash } from "crypto";
8
+ import { toTitleCaseCodename } from "../shared/agent-identity.js";
9
+ // ---------------------------------------------------------------------------
10
+ // Codenames
11
+ // ---------------------------------------------------------------------------
12
+ export const AGENT_CODENAMES = [
13
+ "amber",
14
+ "anchor",
15
+ "autumn",
16
+ "badger",
17
+ "bay",
18
+ "bear",
19
+ "brook",
20
+ "calm",
21
+ "canyon",
22
+ "cedar",
23
+ "clear",
24
+ "cloud",
25
+ "comet",
26
+ "copper",
27
+ "creek",
28
+ "crisp",
29
+ "crest",
30
+ "dawn",
31
+ "delta",
32
+ "dune",
33
+ "ember",
34
+ "falcon",
35
+ "fern",
36
+ "field",
37
+ "firefly",
38
+ "fjord",
39
+ "forest",
40
+ "fox",
41
+ "garden",
42
+ "glade",
43
+ "golden",
44
+ "granite",
45
+ "grove",
46
+ "harbor",
47
+ "hawk",
48
+ "hollow",
49
+ "indigo",
50
+ "ivory",
51
+ "jade",
52
+ "juniper",
53
+ "lagoon",
54
+ "lake",
55
+ "lantern",
56
+ "leaf",
57
+ "lively",
58
+ "lunar",
59
+ "lynx",
60
+ "maple",
61
+ "marsh",
62
+ "meadow",
63
+ "mesa",
64
+ "misty",
65
+ "moon",
66
+ "morrow",
67
+ "moss",
68
+ "noble",
69
+ "oak",
70
+ "olive",
71
+ "opal",
72
+ "otter",
73
+ "owl",
74
+ "peak",
75
+ "pearl",
76
+ "pine",
77
+ "quiet",
78
+ "raven",
79
+ "reef",
80
+ "ridge",
81
+ "river",
82
+ "rook",
83
+ "sage",
84
+ "scarlet",
85
+ "shore",
86
+ "silver",
87
+ "sky",
88
+ "solar",
89
+ "sparrow",
90
+ "spring",
91
+ "star",
92
+ "stone",
93
+ "storm",
94
+ "summit",
95
+ "sun",
96
+ "sunlit",
97
+ "swift",
98
+ "thicket",
99
+ "tidal",
100
+ "timber",
101
+ "trail",
102
+ "valley",
103
+ "verdant",
104
+ "vista",
105
+ "warm",
106
+ "wave",
107
+ "west",
108
+ "wild",
109
+ "willow",
110
+ "wind",
111
+ "winter",
112
+ "wolf",
113
+ "wood",
114
+ "wren",
115
+ ];
116
+ export const AGENT_CODENAME_SPACE = AGENT_CODENAMES.length * AGENT_CODENAMES.length;
117
+ // ---------------------------------------------------------------------------
118
+ // Slug helpers
119
+ // ---------------------------------------------------------------------------
120
+ export function normalizeSlugSegment(input, fallback) {
121
+ const normalized = input
122
+ .normalize("NFKD")
123
+ .replace(/[^\x00-\x7F]/g, "")
124
+ .toLowerCase()
125
+ .replace(/[^a-z0-9]+/g, "-")
126
+ .replace(/^-+|-+$/g, "")
127
+ .replace(/-{2,}/g, "-");
128
+ return normalized || fallback;
129
+ }
130
+ export function normalizeAgentBaseName(input) {
131
+ return normalizeSlugSegment(input, "agent").replace(/-agent$/, "") || "agent";
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // Codename derivation
135
+ // ---------------------------------------------------------------------------
136
+ export function hashStringToIndex(value, modulo) {
137
+ const digest = createHash("sha256").update(value).digest();
138
+ return digest.readUInt16BE(0) % modulo;
139
+ }
140
+ export function codenameFromIndex(index) {
141
+ const normalizedIndex = ((index % AGENT_CODENAME_SPACE) + AGENT_CODENAME_SPACE) % AGENT_CODENAME_SPACE;
142
+ const firstIndex = Math.floor(normalizedIndex / AGENT_CODENAMES.length);
143
+ const secondIndex = normalizedIndex % AGENT_CODENAMES.length;
144
+ const first = AGENT_CODENAMES[firstIndex];
145
+ const second = AGENT_CODENAMES[secondIndex];
146
+ return {
147
+ name: normalizeAgentBaseName(`${first}-${second}`),
148
+ display_name: `${toTitleCaseCodename(first)} ${toTitleCaseCodename(second)}`,
149
+ };
150
+ }
151
+ export function pickLocalCodename(runtimeKey, offset = 0) {
152
+ const index = hashStringToIndex(runtimeKey, AGENT_CODENAME_SPACE) + offset;
153
+ return codenameFromIndex(index);
154
+ }
@@ -1,12 +1,16 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "fs";
2
+ import { randomBytes } from "crypto";
2
3
  import { homedir } from "os";
3
4
  import { dirname, join } from "path";
4
5
  const DEFAULT_STATE_PATH = join(homedir(), ".letagents", "mcp-state.json");
6
+ const STATE_LOCK_WAIT_MS = 25;
7
+ const STATE_LOCK_TIMEOUT_MS = 2_000;
8
+ const STATE_LOCK_STALE_MS = 10_000;
9
+ const STATE_LOCK_SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
5
10
  export function getLocalStatePath() {
6
11
  return process.env.LETAGENTS_STATE_PATH || DEFAULT_STATE_PATH;
7
12
  }
8
- export function readLocalState() {
9
- const statePath = getLocalStatePath();
13
+ function readLocalStateFromPath(statePath) {
10
14
  if (!existsSync(statePath)) {
11
15
  return {};
12
16
  }
@@ -19,18 +23,76 @@ export function readLocalState() {
19
23
  return {};
20
24
  }
21
25
  }
22
- export function writeLocalState(state) {
26
+ export function readLocalState() {
27
+ const statePath = getLocalStatePath();
28
+ return readLocalStateFromPath(statePath);
29
+ }
30
+ function sleepSync(ms) {
31
+ if (ms > 0) {
32
+ Atomics.wait(STATE_LOCK_SLEEP_BUFFER, 0, 0, ms);
33
+ }
34
+ }
35
+ function writeLocalStateUnlocked(statePath, state) {
36
+ const tempPath = `${statePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
37
+ try {
38
+ writeFileSync(tempPath, JSON.stringify(state, null, 2) + "\n", "utf-8");
39
+ renameSync(tempPath, statePath);
40
+ }
41
+ finally {
42
+ rmSync(tempPath, { force: true });
43
+ }
44
+ }
45
+ function withStateLock(callback) {
23
46
  const statePath = getLocalStatePath();
24
47
  mkdirSync(dirname(statePath), { recursive: true });
25
- const tempPath = `${statePath}.tmp`;
26
- writeFileSync(tempPath, JSON.stringify(state, null, 2) + "\n", "utf-8");
27
- renameSync(tempPath, statePath);
48
+ const lockPath = `${statePath}.lock`;
49
+ const startedAt = Date.now();
50
+ while (true) {
51
+ let lockFd = null;
52
+ try {
53
+ lockFd = openSync(lockPath, "wx");
54
+ return callback(statePath);
55
+ }
56
+ catch (error) {
57
+ const err = error;
58
+ if (err.code !== "EEXIST") {
59
+ throw error;
60
+ }
61
+ try {
62
+ const stats = statSync(lockPath);
63
+ if (Date.now() - stats.mtimeMs > STATE_LOCK_STALE_MS) {
64
+ rmSync(lockPath, { force: true });
65
+ continue;
66
+ }
67
+ }
68
+ catch {
69
+ continue;
70
+ }
71
+ if (Date.now() - startedAt >= STATE_LOCK_TIMEOUT_MS) {
72
+ throw new Error(`Timed out acquiring local state lock at ${lockPath}`);
73
+ }
74
+ sleepSync(STATE_LOCK_WAIT_MS);
75
+ }
76
+ finally {
77
+ if (lockFd !== null) {
78
+ closeSync(lockFd);
79
+ rmSync(lockPath, { force: true });
80
+ }
81
+ }
82
+ }
83
+ }
84
+ export function writeLocalState(state) {
85
+ withStateLock((statePath) => {
86
+ writeLocalStateUnlocked(statePath, state);
87
+ });
28
88
  }
29
89
  export function updateLocalState(updater) {
30
- const current = readLocalState();
31
- const updated = updater(current) ?? current;
32
- writeLocalState(updated);
33
- return updated;
90
+ return withStateLock((statePath) => {
91
+ const current = readLocalStateFromPath(statePath);
92
+ const updated = updater(current) ?? current;
93
+ writeLocalStateUnlocked(statePath, updated);
94
+ return updated;
95
+ });
34
96
  }
35
97
  function isExpired(expiresAt) {
36
98
  if (!expiresAt) {
@@ -4,6 +4,9 @@ export function encodeRoomIdPath(roomId) {
4
4
  .map((segment) => encodeURIComponent(segment))
5
5
  .join("/");
6
6
  }
7
+ export function getCanonicalRoomWebPath(roomId) {
8
+ return `/in/${encodeRoomIdPath(roomId)}`;
9
+ }
7
10
  export function looksLikeInviteCode(value) {
8
11
  return /^[A-Z0-9]{4}(?:-[A-Z0-9]{4})+$/.test(value.trim().toUpperCase());
9
12
  }
@@ -2,7 +2,7 @@
2
2
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- import { createHash } from "crypto";
5
+ import { createHash, randomUUID } from "crypto";
6
6
  import { writeFileSync, existsSync } from "fs";
7
7
  import { userInfo } from "os";
8
8
  import { join, dirname } from "path";
@@ -10,12 +10,13 @@ import { execSync } from "child_process";
10
10
  import { SseClient } from "./sse-client.js";
11
11
  import { getRoomFromConfig } from "./config-reader.js";
12
12
  import { getGitRemoteIdentity } from "./git-remote.js";
13
- import { clearPendingDeviceAuth, clearStoredAuth, getLocalStatePath, getPendingDeviceAuth, getStoredAgentIdentity, getStoredAuth, getStoredCurrentRoom, getStoredRoomSession, saveRoomSession, setStoredAgentIdentity, setPendingDeviceAuth, setStoredAuth, touchRoomSession, } from "./local-state.js";
14
- import { encodeRoomIdPath, looksLikeInviteCode, normalizeInviteCode, } from "./room-id.js";
13
+ import { AGENT_CODENAME_SPACE, normalizeSlugSegment, normalizeAgentBaseName, pickLocalCodename, } from "./codenames.js";
14
+ import { clearPendingDeviceAuth, clearStoredAuth, getLocalStatePath, getPendingDeviceAuth, getStoredAgentIdentity, getStoredAuth, getStoredCurrentRoom, getStoredRoomSession, saveRoomSession, setStoredAgentIdentity, setPendingDeviceAuth, setStoredAuth, touchRoomSession, updateLocalState, } from "./local-state.js";
15
+ import { encodeRoomIdPath, getCanonicalRoomWebPath, looksLikeInviteCode, normalizeInviteCode, } from "./room-id.js";
15
16
  import { buildAgentActorLabel, formatOwnerAttribution, inferAgentIdeLabel, toTitleCaseCodename, } from "../shared/agent-identity.js";
16
- const CURRENT_AGENT_IDENTITY_KEY = getAgentIdentityStorageKey();
17
17
  let currentRoom = null;
18
- let currentAgentIdentity = getStoredAgentIdentity(CURRENT_AGENT_IDENTITY_KEY);
18
+ let currentAgentIdentityKey = "";
19
+ let currentAgentIdentity = null;
19
20
  let currentAuthenticatedAccount = undefined;
20
21
  let currentAuthenticatedAccountSource = null;
21
22
  let currentAuthenticatedEnvToken = null;
@@ -27,6 +28,11 @@ const AGENT_NAME = (process.env.LETAGENTS_AGENT_NAME || process.env.AGENT_NAME |
27
28
  const AGENT_DISPLAY_NAME = (process.env.LETAGENTS_AGENT_DISPLAY_NAME || "").trim();
28
29
  const AGENT_IDE_LABEL = (process.env.LETAGENTS_AGENT_IDE || process.env.AGENT_IDE || "").trim();
29
30
  const AGENT_OWNER_LABEL = (process.env.LETAGENTS_AGENT_OWNER_LABEL || "").trim();
31
+ const EXPLICIT_AGENT_IDENTITY_KEY = getExplicitAgentIdentityStorageKey();
32
+ const AGENT_INSTANCE_UUID = randomUUID();
33
+ currentAgentIdentityKey =
34
+ EXPLICIT_AGENT_IDENTITY_KEY ?? `instance:${AGENT_INSTANCE_UUID}`;
35
+ currentAgentIdentity = getStoredAgentIdentity(currentAgentIdentityKey);
30
36
  // ---------------------------------------------------------------------------
31
37
  // Helpers
32
38
  // ---------------------------------------------------------------------------
@@ -43,130 +49,17 @@ function readCommandOutput(command, cwd = process.cwd()) {
43
49
  return null;
44
50
  }
45
51
  }
46
- function normalizeSlugSegment(input, fallback) {
47
- const normalized = input
48
- .normalize("NFKD")
49
- .replace(/[^\x00-\x7F]/g, "")
50
- .toLowerCase()
51
- .replace(/[^a-z0-9]+/g, "-")
52
- .replace(/^-+|-+$/g, "")
53
- .replace(/-{2,}/g, "-");
54
- return normalized || fallback;
55
- }
56
- function normalizeAgentBaseName(input) {
57
- return normalizeSlugSegment(input, "agent").replace(/-agent$/, "") || "agent";
58
- }
52
+ // normalizeSlugSegment, normalizeAgentBaseName, AGENT_CODENAMES,
53
+ // AGENT_CODENAME_SPACE, hashStringToIndex, codenameFromIndex,
54
+ // pickLocalCodename — all imported from ./codenames.js
59
55
  function isCodexRuntime() {
60
56
  return Boolean(process.env.CODEX_THREAD_ID ||
61
57
  process.env.CODEX_SHELL ||
62
58
  process.env.CODEX_CI ||
63
59
  process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE);
64
60
  }
65
- const AGENT_CODENAMES = [
66
- "amber",
67
- "anchor",
68
- "autumn",
69
- "badger",
70
- "bay",
71
- "bear",
72
- "brook",
73
- "calm",
74
- "canyon",
75
- "cedar",
76
- "clear",
77
- "cloud",
78
- "comet",
79
- "copper",
80
- "creek",
81
- "crisp",
82
- "crest",
83
- "dawn",
84
- "delta",
85
- "dune",
86
- "ember",
87
- "falcon",
88
- "fern",
89
- "field",
90
- "firefly",
91
- "fjord",
92
- "forest",
93
- "fox",
94
- "garden",
95
- "glade",
96
- "golden",
97
- "granite",
98
- "grove",
99
- "harbor",
100
- "hawk",
101
- "hollow",
102
- "indigo",
103
- "ivory",
104
- "jade",
105
- "juniper",
106
- "lagoon",
107
- "lake",
108
- "lantern",
109
- "leaf",
110
- "lively",
111
- "lunar",
112
- "lynx",
113
- "maple",
114
- "marsh",
115
- "meadow",
116
- "mesa",
117
- "misty",
118
- "moon",
119
- "morrow",
120
- "moss",
121
- "noble",
122
- "oak",
123
- "olive",
124
- "opal",
125
- "otter",
126
- "owl",
127
- "peak",
128
- "pearl",
129
- "pine",
130
- "quiet",
131
- "raven",
132
- "reef",
133
- "ridge",
134
- "river",
135
- "rook",
136
- "sage",
137
- "scarlet",
138
- "shore",
139
- "silver",
140
- "sky",
141
- "solar",
142
- "sparrow",
143
- "spring",
144
- "star",
145
- "stone",
146
- "storm",
147
- "summit",
148
- "sun",
149
- "sunlit",
150
- "swift",
151
- "thicket",
152
- "tidal",
153
- "timber",
154
- "trail",
155
- "valley",
156
- "verdant",
157
- "vista",
158
- "warm",
159
- "wave",
160
- "west",
161
- "wild",
162
- "willow",
163
- "wind",
164
- "winter",
165
- "wolf",
166
- "wood",
167
- "wren",
168
- ];
169
- function getAgentIdentityStorageKey() {
61
+ const AGENT_IDENTITY_SLOT_SUFFIX = ":slot:";
62
+ function getExplicitAgentIdentityStorageKey() {
170
63
  const runtimeSignals = [
171
64
  process.env.LETAGENTS_AGENT_INSTANCE_ID,
172
65
  process.env.CODEX_THREAD_ID && `codex:${process.env.CODEX_THREAD_ID}`,
@@ -177,11 +70,10 @@ function getAgentIdentityStorageKey() {
177
70
  if (runtimeSignals.length) {
178
71
  return runtimeSignals[0];
179
72
  }
180
- return `cwd:${process.cwd()}`;
73
+ return null;
181
74
  }
182
- function hashStringToIndex(value, modulo) {
183
- const digest = createHash("sha256").update(value).digest();
184
- return digest.readUInt16BE(0) % modulo;
75
+ function getFallbackAgentIdentityNamespaceKey(ideLabel) {
76
+ return `cwd:${process.cwd()}:ide:${normalizeAgentBaseName(ideLabel)}`;
185
77
  }
186
78
  function detectAgentIdeLabel() {
187
79
  if (AGENT_IDE_LABEL) {
@@ -194,6 +86,125 @@ function detectAgentIdeLabel() {
194
86
  const inferred = inferAgentIdeLabel(explicitName);
195
87
  return inferred || "Agent";
196
88
  }
89
+ function getFallbackSlotPrefix(namespaceKey) {
90
+ return `${namespaceKey}${AGENT_IDENTITY_SLOT_SUFFIX}`;
91
+ }
92
+ function getFallbackSlotKey(namespaceKey, slotIndex) {
93
+ return `${getFallbackSlotPrefix(namespaceKey)}${slotIndex}`;
94
+ }
95
+ function parseFallbackSlotIndex(identityKey, namespaceKey) {
96
+ const prefix = getFallbackSlotPrefix(namespaceKey);
97
+ if (!identityKey.startsWith(prefix)) {
98
+ return null;
99
+ }
100
+ const slotIndex = Number.parseInt(identityKey.slice(prefix.length), 10);
101
+ return Number.isInteger(slotIndex) && slotIndex >= 0 ? slotIndex : null;
102
+ }
103
+ function isProcessAlive(pid) {
104
+ if (!pid || !Number.isInteger(pid) || pid <= 0) {
105
+ return false;
106
+ }
107
+ try {
108
+ process.kill(pid, 0);
109
+ return true;
110
+ }
111
+ catch (error) {
112
+ const err = error;
113
+ return err.code === "EPERM";
114
+ }
115
+ }
116
+ function claimFallbackIdentityKey(namespaceKey) {
117
+ let claimedKey = getFallbackSlotKey(namespaceKey, 0);
118
+ let claimedIdentity = null;
119
+ const now = new Date().toISOString();
120
+ updateLocalState((state) => {
121
+ const identities = state.agent_identities ?? {};
122
+ const leases = state.agent_identity_leases ?? {};
123
+ const namespacePrefix = getFallbackSlotPrefix(namespaceKey);
124
+ for (const [identityKey, lease] of Object.entries(leases)) {
125
+ if (!identityKey.startsWith(namespacePrefix) || lease.namespace_key !== namespaceKey) {
126
+ continue;
127
+ }
128
+ if (!isProcessAlive(lease.pid)) {
129
+ delete leases[identityKey];
130
+ }
131
+ }
132
+ const activeLeaseForPid = Object.entries(leases).find(([identityKey, lease]) => identityKey.startsWith(namespacePrefix) &&
133
+ lease.namespace_key === namespaceKey &&
134
+ lease.pid === process.pid);
135
+ if (activeLeaseForPid) {
136
+ const [identityKey, lease] = activeLeaseForPid;
137
+ lease.updated_at = now;
138
+ claimedKey = identityKey;
139
+ claimedIdentity = identities[identityKey] ?? null;
140
+ state.agent_identity_leases = leases;
141
+ return state;
142
+ }
143
+ const slotKeys = new Set();
144
+ for (const identityKey of Object.keys(identities)) {
145
+ if (identityKey.startsWith(namespacePrefix)) {
146
+ slotKeys.add(identityKey);
147
+ }
148
+ }
149
+ for (const identityKey of Object.keys(leases)) {
150
+ if (identityKey.startsWith(namespacePrefix)) {
151
+ slotKeys.add(identityKey);
152
+ }
153
+ }
154
+ const sortedSlotKeys = [...slotKeys].sort((left, right) => {
155
+ const leftIndex = parseFallbackSlotIndex(left, namespaceKey) ?? Number.MAX_SAFE_INTEGER;
156
+ const rightIndex = parseFallbackSlotIndex(right, namespaceKey) ?? Number.MAX_SAFE_INTEGER;
157
+ return leftIndex - rightIndex;
158
+ });
159
+ for (const identityKey of sortedSlotKeys) {
160
+ if (leases[identityKey]) {
161
+ continue;
162
+ }
163
+ leases[identityKey] = {
164
+ namespace_key: namespaceKey,
165
+ pid: process.pid,
166
+ acquired_at: now,
167
+ updated_at: now,
168
+ };
169
+ claimedKey = identityKey;
170
+ claimedIdentity = identities[identityKey] ?? null;
171
+ state.agent_identity_leases = leases;
172
+ return state;
173
+ }
174
+ let nextSlotIndex = 0;
175
+ while (slotKeys.has(getFallbackSlotKey(namespaceKey, nextSlotIndex))) {
176
+ nextSlotIndex += 1;
177
+ }
178
+ claimedKey = getFallbackSlotKey(namespaceKey, nextSlotIndex);
179
+ leases[claimedKey] = {
180
+ namespace_key: namespaceKey,
181
+ pid: process.pid,
182
+ acquired_at: now,
183
+ updated_at: now,
184
+ };
185
+ claimedIdentity = identities[claimedKey] ?? null;
186
+ state.agent_identity_leases = leases;
187
+ return state;
188
+ });
189
+ return {
190
+ identityKey: claimedKey,
191
+ identity: claimedIdentity,
192
+ };
193
+ }
194
+ function ensureAgentIdentityKey(ideLabel) {
195
+ if (EXPLICIT_AGENT_IDENTITY_KEY) {
196
+ currentAgentIdentityKey = EXPLICIT_AGENT_IDENTITY_KEY;
197
+ currentAgentIdentity = getStoredAgentIdentity(currentAgentIdentityKey);
198
+ return currentAgentIdentityKey;
199
+ }
200
+ // v1: Use per-instance UUID for guaranteed distinct identity.
201
+ // Each MCP process gets a unique UUID — no slot/PID coordination needed.
202
+ // Trade-off: process restart = new identity (accepted for v1).
203
+ const uuidKey = `instance:${AGENT_INSTANCE_UUID}`;
204
+ currentAgentIdentityKey = uuidKey;
205
+ currentAgentIdentity = getStoredAgentIdentity(currentAgentIdentityKey);
206
+ return currentAgentIdentityKey;
207
+ }
197
208
  async function getAuthenticatedAgentDirectory() {
198
209
  try {
199
210
  const result = await apiCall("/agents/me");
@@ -213,9 +224,9 @@ async function getAuthenticatedAgentDirectory() {
213
224
  return null;
214
225
  }
215
226
  }
216
- function shouldReuseStoredIdentity(identity) {
227
+ function shouldReuseStoredIdentity(identity, identityKey) {
217
228
  return Boolean(identity &&
218
- identity.runtime_key === CURRENT_AGENT_IDENTITY_KEY &&
229
+ identity.runtime_key === identityKey &&
219
230
  identity.display_name?.trim() &&
220
231
  identity.ide_label?.trim() &&
221
232
  identity.owner_attribution?.trim());
@@ -236,43 +247,36 @@ function resolveExplicitAgentIdentity() {
236
247
  }
237
248
  return null;
238
249
  }
239
- function pickLocalCodename(runtimeKey, offset = 0) {
240
- const index = (hashStringToIndex(runtimeKey, AGENT_CODENAMES.length) + offset) % AGENT_CODENAMES.length;
241
- const word = AGENT_CODENAMES[index];
242
- return {
243
- name: normalizeAgentBaseName(word),
244
- display_name: toTitleCaseCodename(word),
245
- };
246
- }
247
- async function resolveAgentName(authAvailable) {
250
+ // pickLocalCodename imported from ./codenames.js
251
+ async function resolveAgentName(authAvailable, identityKey) {
248
252
  const explicit = resolveExplicitAgentIdentity();
249
253
  if (explicit) {
250
254
  return explicit;
251
255
  }
252
- if (shouldReuseStoredIdentity(currentAgentIdentity)) {
256
+ if (shouldReuseStoredIdentity(currentAgentIdentity, identityKey)) {
253
257
  return {
254
258
  name: currentAgentIdentity.name,
255
259
  display_name: currentAgentIdentity.display_name,
256
260
  };
257
261
  }
258
262
  if (!authAvailable) {
259
- return pickLocalCodename(CURRENT_AGENT_IDENTITY_KEY);
263
+ return pickLocalCodename(identityKey);
260
264
  }
261
265
  const directory = await getAuthenticatedAgentDirectory();
262
266
  const existingNames = new Set((directory?.agents ?? [])
263
267
  .map((agent) => normalizeAgentBaseName(agent.name || ""))
264
268
  .filter(Boolean));
265
- for (let offset = 0; offset < AGENT_CODENAMES.length; offset += 1) {
266
- const candidate = pickLocalCodename(CURRENT_AGENT_IDENTITY_KEY, offset);
269
+ for (let offset = 0; offset < AGENT_CODENAME_SPACE; offset += 1) {
270
+ const candidate = pickLocalCodename(identityKey, offset);
267
271
  if (!existingNames.has(candidate.name)) {
268
272
  return candidate;
269
273
  }
270
274
  }
271
275
  const fallbackHash = createHash("sha256")
272
- .update(CURRENT_AGENT_IDENTITY_KEY)
276
+ .update(identityKey)
273
277
  .digest("hex")
274
278
  .slice(0, 4);
275
- const fallback = pickLocalCodename(CURRENT_AGENT_IDENTITY_KEY);
279
+ const fallback = pickLocalCodename(identityKey);
276
280
  return {
277
281
  name: `${fallback.name}-${fallbackHash}`,
278
282
  display_name: `${fallback.display_name} ${fallbackHash.toUpperCase()}`,
@@ -377,6 +381,7 @@ function toPublicAgentIdentity(identity) {
377
381
  actor_label: identity.actor_label,
378
382
  canonical_key: identity.canonical_key ?? null,
379
383
  runtime_key: identity.runtime_key ?? null,
384
+ agent_instance_id: AGENT_INSTANCE_UUID,
380
385
  source: identity.source,
381
386
  };
382
387
  }
@@ -384,8 +389,9 @@ async function ensureAgentIdentity() {
384
389
  const owner = await resolveOwnerContext();
385
390
  const authAvailable = Boolean(getLetagentsToken());
386
391
  const ideLabel = detectAgentIdeLabel();
392
+ const identityKey = ensureAgentIdentityKey(ideLabel);
387
393
  const ownerAttribution = formatOwnerAttribution(owner.label);
388
- const { name, display_name: displayName } = await resolveAgentName(authAvailable);
394
+ const { name, display_name: displayName } = await resolveAgentName(authAvailable, identityKey);
389
395
  const actorLabel = buildAgentActorLabel({
390
396
  display_name: displayName,
391
397
  owner_label: owner.label,
@@ -399,7 +405,7 @@ async function ensureAgentIdentity() {
399
405
  ide_label: ideLabel,
400
406
  actor_label: actorLabel,
401
407
  canonical_key: owner.login ? `${owner.login}/${name}` : null,
402
- runtime_key: CURRENT_AGENT_IDENTITY_KEY,
408
+ runtime_key: identityKey,
403
409
  source: "local",
404
410
  resolved_at: new Date().toISOString(),
405
411
  };
@@ -452,7 +458,7 @@ async function ensureAgentIdentity() {
452
458
  currentAgentIdentity = setStoredAgentIdentity({
453
459
  ...resolved,
454
460
  resolved_at: new Date().toISOString(),
455
- }, CURRENT_AGENT_IDENTITY_KEY);
461
+ }, identityKey);
456
462
  }
457
463
  return currentAgentIdentity ?? resolved;
458
464
  }
@@ -628,22 +634,32 @@ function toRoomState(input) {
628
634
  joined_via: input.joined_via,
629
635
  };
630
636
  }
637
+ function getCanonicalRoomWebUrl(roomId) {
638
+ return new URL(getCanonicalRoomWebPath(roomId), `${API_URL}/`).toString();
639
+ }
640
+ function withCanonicalRoomLink(roomId, payload) {
641
+ return {
642
+ ...payload,
643
+ room_path: getCanonicalRoomWebPath(roomId),
644
+ room_url: getCanonicalRoomWebUrl(roomId),
645
+ };
646
+ }
631
647
  function toPublicRoomState(state) {
632
648
  if (!state) {
633
649
  return null;
634
650
  }
635
- return {
651
+ return withCanonicalRoomLink(state.room_id, {
636
652
  room_id: state.room_id,
637
653
  code: state.code ?? null,
638
654
  display_name: state.display_name ?? null,
639
655
  joined_via: state.joined_via,
640
- };
656
+ });
641
657
  }
642
658
  function toPublicStoredRoomSession(session) {
643
659
  if (!session) {
644
660
  return null;
645
661
  }
646
- return {
662
+ return withCanonicalRoomLink(session.room_id, {
647
663
  room_id: session.room_id,
648
664
  code: session.code ?? null,
649
665
  display_name: session.display_name ?? null,
@@ -651,12 +667,12 @@ function toPublicStoredRoomSession(session) {
651
667
  joined_at: session.joined_at,
652
668
  last_seen_at: session.last_seen_at,
653
669
  last_message_id: session.last_message_id ?? null,
654
- };
670
+ });
655
671
  }
656
672
  function toPublicRoomResponse(response, fallbackRoomId) {
657
673
  const { id: _legacyId, project_id: _legacyProjectId, ...rest } = response;
658
674
  return {
659
- ...rest,
675
+ ...withCanonicalRoomLink(typeof rest.room_id === "string" ? rest.room_id : fallbackRoomId, rest),
660
676
  room_id: typeof rest.room_id === "string" ? rest.room_id : fallbackRoomId,
661
677
  };
662
678
  }
@@ -868,18 +884,35 @@ server.resource("room_messages", new ResourceTemplate("letagents://rooms/{room_i
868
884
  const normalizedRoomId = String(room_id);
869
885
  const storedSession = getStoredRoomSession(normalizedRoomId) ??
870
886
  (currentRoom?.room_id === normalizedRoomId ? getStoredCurrentRoom() : null);
871
- const result = await roomScopedApiCall({
872
- room_id: normalizedRoomId,
873
- project_id: storedSession?.project_id ?? null,
874
- room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
875
- project_path: (projectId) => `/projects/${encodeURIComponent(projectId)}/messages`,
876
- });
887
+ // Paginate through all pages to return full message history
888
+ const allMessages = [];
889
+ let afterCursor;
890
+ for (;;) {
891
+ const query = new URLSearchParams();
892
+ if (afterCursor)
893
+ query.set("after", afterCursor);
894
+ const qs = query.toString();
895
+ const result = await roomScopedApiCall({
896
+ room_id: normalizedRoomId,
897
+ project_id: storedSession?.project_id ?? null,
898
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages${qs ? `?${qs}` : ""}`,
899
+ project_path: (projectId) => `/projects/${encodeURIComponent(projectId)}/messages${qs ? `?${qs}` : ""}`,
900
+ });
901
+ const msgs = result.messages ?? [];
902
+ allMessages.push(...msgs);
903
+ if (!result.has_more || msgs.length === 0)
904
+ break;
905
+ const lastMsg = msgs[msgs.length - 1];
906
+ if (!lastMsg?.id)
907
+ break;
908
+ afterCursor = lastMsg.id;
909
+ }
877
910
  return {
878
911
  contents: [
879
912
  {
880
913
  uri: uri.href,
881
914
  mimeType: "application/json",
882
- text: JSON.stringify(result, null, 2),
915
+ text: JSON.stringify({ messages: allMessages }, null, 2),
883
916
  },
884
917
  ],
885
918
  };
@@ -972,7 +1005,7 @@ server.tool("get_current_room", "Get information about the currently joined room
972
1005
  ? {
973
1006
  connected: true,
974
1007
  ...toPublicRoomState(currentRoom),
975
- agent_identity: toPublicAgentIdentity(currentAgentIdentity ?? getStoredAgentIdentity(CURRENT_AGENT_IDENTITY_KEY)),
1008
+ agent_identity: toPublicAgentIdentity(currentAgentIdentity ?? getStoredAgentIdentity(currentAgentIdentityKey)),
976
1009
  auth: getStoredAuth()
977
1010
  ? {
978
1011
  source: process.env.LETAGENTS_TOKEN ? "env" : "local_state",
@@ -1163,15 +1196,31 @@ server.tool("get_board", "Get the current task board for the room. By default sh
1163
1196
  params.set("status", status);
1164
1197
  if (open_only !== false)
1165
1198
  params.set("open", "true");
1166
- const qs = params.toString();
1167
- const result = await roomScopedApiCall({
1168
- room_id: targetRoomId,
1169
- project_id: targetProjectId,
1170
- room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks${qs ? `?${qs}` : ""}`,
1171
- project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks${qs ? `?${qs}` : ""}`,
1172
- });
1199
+ // Paginate through all pages to return the full board
1200
+ const allTasks = [];
1201
+ let afterCursor;
1202
+ for (;;) {
1203
+ const pageParams = new URLSearchParams(params);
1204
+ if (afterCursor)
1205
+ pageParams.set("after", afterCursor);
1206
+ const qs = pageParams.toString();
1207
+ const result = await roomScopedApiCall({
1208
+ room_id: targetRoomId,
1209
+ project_id: targetProjectId,
1210
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks${qs ? `?${qs}` : ""}`,
1211
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks${qs ? `?${qs}` : ""}`,
1212
+ });
1213
+ const tasks = result.tasks ?? [];
1214
+ allTasks.push(...tasks);
1215
+ if (!result.has_more || tasks.length === 0)
1216
+ break;
1217
+ const lastTask = tasks[tasks.length - 1];
1218
+ if (!lastTask?.id)
1219
+ break;
1220
+ afterCursor = lastTask.id;
1221
+ }
1173
1222
  return {
1174
- content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }],
1223
+ content: [{ type: "text", text: JSON.stringify({ success: true, tasks: allTasks }, null, 2) }],
1175
1224
  };
1176
1225
  });
1177
1226
  server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted' " +
@@ -1497,17 +1546,41 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
1497
1546
  }, async ({ room_id }) => {
1498
1547
  const targetRoomId = getTargetRoomId(room_id);
1499
1548
  const targetProjectId = getFallbackProjectId();
1500
- const result = await roomScopedApiCall({
1501
- room_id: targetRoomId,
1502
- project_id: targetProjectId,
1503
- room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
1504
- project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
1505
- });
1549
+ // Paginate through all pages to honor the "read all messages" contract
1550
+ const allMessages = [];
1551
+ let afterCursor;
1552
+ let roomIdFromResponse;
1553
+ for (;;) {
1554
+ const query = new URLSearchParams();
1555
+ if (afterCursor)
1556
+ query.set("after", afterCursor);
1557
+ const qs = query.toString();
1558
+ const result = await roomScopedApiCall({
1559
+ room_id: targetRoomId,
1560
+ project_id: targetProjectId,
1561
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages${qs ? `?${qs}` : ""}`,
1562
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages${qs ? `?${qs}` : ""}`,
1563
+ });
1564
+ roomIdFromResponse = roomIdFromResponse || result.room_id || result.project_id;
1565
+ const msgs = result.messages ?? [];
1566
+ allMessages.push(...msgs);
1567
+ if (!result.has_more || msgs.length === 0)
1568
+ break;
1569
+ // Use the last message ID as the cursor for the next page
1570
+ const lastMsg = msgs[msgs.length - 1];
1571
+ if (!lastMsg?.id)
1572
+ break;
1573
+ afterCursor = lastMsg.id;
1574
+ }
1575
+ const output = { messages: allMessages };
1576
+ if (roomIdFromResponse) {
1577
+ output[targetRoomId ? "room_id" : "project_id"] = roomIdFromResponse;
1578
+ }
1506
1579
  return {
1507
1580
  content: [
1508
1581
  {
1509
1582
  type: "text",
1510
- text: JSON.stringify(result, null, 2),
1583
+ text: JSON.stringify(output, null, 2),
1511
1584
  },
1512
1585
  ],
1513
1586
  };
@@ -1535,21 +1608,49 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat roo
1535
1608
  params.set("after", after_message_id);
1536
1609
  params.set("timeout", String(serverTimeout));
1537
1610
  const queryString = params.toString();
1538
- const result = await roomScopedApiCall({
1611
+ const firstResult = await roomScopedApiCall({
1539
1612
  room_id: targetRoomId,
1540
1613
  project_id: targetProjectId,
1541
1614
  room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages/poll?${queryString}`,
1542
1615
  project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages/poll?${queryString}`,
1543
1616
  options: { signal: AbortSignal.timeout(clientTimeout) },
1544
1617
  });
1618
+ const allMessages = [...(firstResult.messages ?? [])];
1619
+ const roomIdFromResponse = firstResult.room_id || firstResult.project_id;
1620
+ // If the immediate response has more pages, paginate through them
1621
+ if (firstResult.has_more && allMessages.length > 0) {
1622
+ let afterCursor = allMessages[allMessages.length - 1]?.id;
1623
+ while (afterCursor) {
1624
+ const pageParams = new URLSearchParams();
1625
+ pageParams.set("after", afterCursor);
1626
+ const qs = pageParams.toString();
1627
+ const page = await roomScopedApiCall({
1628
+ room_id: targetRoomId,
1629
+ project_id: targetProjectId,
1630
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages?${qs}`,
1631
+ project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages?${qs}`,
1632
+ });
1633
+ const msgs = page.messages ?? [];
1634
+ allMessages.push(...msgs);
1635
+ if (!page.has_more || msgs.length === 0)
1636
+ break;
1637
+ afterCursor = msgs[msgs.length - 1]?.id;
1638
+ if (!afterCursor)
1639
+ break;
1640
+ }
1641
+ }
1642
+ const output = { messages: allMessages };
1643
+ if (roomIdFromResponse) {
1644
+ output[targetRoomId ? "room_id" : "project_id"] = roomIdFromResponse;
1645
+ }
1545
1646
  if (targetRoomId) {
1546
- touchRoomSession(targetRoomId, getLastMessageId(result));
1647
+ touchRoomSession(targetRoomId, getLastMessageId(output));
1547
1648
  }
1548
1649
  return {
1549
1650
  content: [
1550
1651
  {
1551
1652
  type: "text",
1552
- text: JSON.stringify(result, null, 2),
1653
+ text: JSON.stringify(output, null, 2),
1553
1654
  },
1554
1655
  ],
1555
1656
  };
@@ -1592,7 +1693,7 @@ server.tool("get_onboarding_status", "Inspect local Let Agents MCP auth and room
1592
1693
  account: storedAuth?.account ?? null,
1593
1694
  token_expires_at: storedAuth?.expires_at ?? null,
1594
1695
  pending_device_auth: pendingAuth,
1595
- agent_identity: toPublicAgentIdentity(currentAgentIdentity ?? getStoredAgentIdentity(CURRENT_AGENT_IDENTITY_KEY)),
1696
+ agent_identity: toPublicAgentIdentity(currentAgentIdentity ?? getStoredAgentIdentity(currentAgentIdentityKey)),
1596
1697
  current_room: toPublicRoomState(currentRoom),
1597
1698
  saved_current_room: toPublicStoredRoomSession(savedCurrentRoom),
1598
1699
  detected_room_from_context: detectedRoom,
@@ -1763,7 +1864,11 @@ server.tool("poll_device_auth", "Poll a pending GitHub Device Flow request. On s
1763
1864
  account: storedAuth.account ?? null,
1764
1865
  expires_at: storedAuth.expires_at ?? null,
1765
1866
  auto_joined_room: joinedRoom,
1867
+ ...(joinedRoom?.room_id
1868
+ ? withCanonicalRoomLink(joinedRoom.room_id, {})
1869
+ : {}),
1766
1870
  agent_identity: toPublicAgentIdentity(agentIdentity),
1871
+ hint: "You can change your agent's display name anytime using the set_agent_name tool.",
1767
1872
  }, null, 2),
1768
1873
  },
1769
1874
  ],
@@ -1787,6 +1892,95 @@ server.tool("clear_saved_auth", "Clear any locally saved LetAgents auth token an
1787
1892
  ],
1788
1893
  };
1789
1894
  });
1895
+ server.tool("set_agent_name", "Set or change the agent's display name. The agent will be known by this name in the room. Use this to pick a custom name instead of the auto-generated codename.", {
1896
+ name: z
1897
+ .string()
1898
+ .min(2)
1899
+ .max(64)
1900
+ .describe("The desired display name for this agent (2-64 characters)."),
1901
+ }, async ({ name: desiredName }) => {
1902
+ const trimmedName = desiredName.trim();
1903
+ if (trimmedName.length < 2 || trimmedName.length > 64) {
1904
+ return {
1905
+ content: [
1906
+ {
1907
+ type: "text",
1908
+ text: JSON.stringify({ success: false, error: "Name must be between 2 and 64 characters." }, null, 2),
1909
+ },
1910
+ ],
1911
+ };
1912
+ }
1913
+ const authAvailable = Boolean(getLetagentsToken());
1914
+ if (!authAvailable) {
1915
+ return {
1916
+ content: [
1917
+ {
1918
+ type: "text",
1919
+ text: JSON.stringify({ success: false, error: "Authentication required. Run start_device_auth first." }, null, 2),
1920
+ },
1921
+ ],
1922
+ };
1923
+ }
1924
+ const slugName = normalizeAgentBaseName(trimmedName);
1925
+ try {
1926
+ const owner = await resolveOwnerContext();
1927
+ const registered = await apiCall("/agents", {
1928
+ method: "POST",
1929
+ body: JSON.stringify({
1930
+ name: slugName,
1931
+ display_name: trimmedName,
1932
+ owner_label: owner.label,
1933
+ }),
1934
+ });
1935
+ const ideLabel = detectAgentIdeLabel();
1936
+ const ownerAttribution = formatOwnerAttribution(owner.label);
1937
+ const actorLabel = buildAgentActorLabel({
1938
+ display_name: trimmedName,
1939
+ owner_label: owner.label,
1940
+ ide_label: ideLabel,
1941
+ });
1942
+ const updatedIdentity = {
1943
+ name: slugName,
1944
+ display_name: trimmedName,
1945
+ owner_label: owner.label,
1946
+ owner_attribution: ownerAttribution,
1947
+ ide_label: ideLabel,
1948
+ actor_label: actorLabel,
1949
+ canonical_key: typeof registered.canonical_key === "string"
1950
+ ? registered.canonical_key
1951
+ : owner.login ? `${owner.login}/${slugName}` : null,
1952
+ runtime_key: currentAgentIdentityKey,
1953
+ source: "api",
1954
+ resolved_at: new Date().toISOString(),
1955
+ };
1956
+ currentAgentIdentity = setStoredAgentIdentity(updatedIdentity, currentAgentIdentityKey);
1957
+ return {
1958
+ content: [
1959
+ {
1960
+ type: "text",
1961
+ text: JSON.stringify({
1962
+ success: true,
1963
+ message: `Agent name changed to "${trimmedName}".`,
1964
+ agent_identity: toPublicAgentIdentity(currentAgentIdentity),
1965
+ }, null, 2),
1966
+ },
1967
+ ],
1968
+ };
1969
+ }
1970
+ catch (error) {
1971
+ return {
1972
+ content: [
1973
+ {
1974
+ type: "text",
1975
+ text: JSON.stringify({
1976
+ success: false,
1977
+ error: `Failed to set name: ${error instanceof Error ? error.message : String(error)}`,
1978
+ }, null, 2),
1979
+ },
1980
+ ],
1981
+ };
1982
+ }
1983
+ });
1790
1984
  server.tool("resume_room_session", "Rejoin the last locally saved room context, or a specific saved room, after a restart. This recreates participation in the room; it does not preserve a prior server-side session ID.", {
1791
1985
  room_id: z
1792
1986
  .string()
@@ -1817,7 +2011,7 @@ server.tool("resume_room_session", "Rejoin the last locally saved room context,
1817
2011
  rejoined_from_local_state: true,
1818
2012
  server_session_resumed: false,
1819
2013
  last_message_id_before_restart: savedRoom.last_message_id ?? null,
1820
- room: joined.room,
2014
+ room: toPublicRoomState(joined.room),
1821
2015
  agent_identity: toPublicAgentIdentity(agentIdentity),
1822
2016
  }, null, 2),
1823
2017
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letagents",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Let Agents Chat — MCP server for AI agent communication",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/server.js",