letagents 0.12.8 â 0.12.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -15
- package/dist/mcp/agent-prompt-delivery.js +20 -0
- package/dist/mcp/codex-session/app-server.js +89 -0
- package/dist/mcp/codex-session/index.js +10 -0
- package/dist/mcp/codex-session/rpc-client.js +85 -0
- package/dist/mcp/codex-session/runtime-bridge.js +254 -0
- package/dist/mcp/codex-session/runtime-summary.js +220 -0
- package/dist/mcp/codex-session/session-inspection.js +100 -0
- package/dist/mcp/codex-session/session-mapper.js +51 -0
- package/dist/mcp/codex-session/session-start.js +182 -0
- package/dist/mcp/codex-session/session-status.js +77 -0
- package/dist/mcp/codex-session/session-stop.js +43 -0
- package/dist/mcp/codex-session/session-supervisor.js +111 -0
- package/dist/mcp/codex-session/start-prompt.js +49 -0
- package/dist/mcp/codex-session/types.js +1 -0
- package/dist/mcp/codex-session.js +1 -500
- package/dist/mcp/config-reader.js +10 -2
- package/dist/mcp/git-remote.js +90 -14
- package/dist/mcp/local-state/agent-identity.js +26 -0
- package/dist/mcp/local-state/agent-sessions.js +66 -0
- package/dist/mcp/local-state/auth.js +57 -0
- package/dist/mcp/local-state/codex-live-sessions.js +59 -0
- package/dist/mcp/local-state/index.js +8 -0
- package/dist/mcp/local-state/local-chat.js +808 -0
- package/dist/mcp/local-state/room-sessions.js +51 -0
- package/dist/mcp/local-state/storage.js +95 -0
- package/dist/mcp/local-state/types.js +1 -0
- package/dist/mcp/local-state.js +1 -286
- package/dist/mcp/managed-agent-providers.js +65 -0
- package/dist/mcp/rental-tools/activity.js +37 -0
- package/dist/mcp/rental-tools/budget.js +36 -0
- package/dist/mcp/rental-tools/context.js +62 -0
- package/dist/mcp/rental-tools/lifecycle.js +47 -0
- package/dist/mcp/rental-tools/metering.js +73 -0
- package/dist/mcp/rental-tools/patches.js +100 -0
- package/dist/mcp/rental-tools/provider.js +117 -0
- package/dist/mcp/rental-tools/shared.js +19 -0
- package/dist/mcp/rental-tools/types.js +1 -0
- package/dist/mcp/rental-tools.js +7 -0
- package/dist/mcp/repo-visibility.js +8 -6
- package/dist/mcp/server/register-tools.js +19 -0
- package/dist/mcp/server/repo-context.js +28 -0
- package/dist/mcp/server/resources.js +77 -0
- package/dist/mcp/server/runtime/agent-sessions.js +134 -0
- package/dist/mcp/server/runtime/api.js +82 -0
- package/dist/mcp/server/runtime/auth-cache.js +20 -0
- package/dist/mcp/server/runtime/device-auth.js +48 -0
- package/dist/mcp/server/runtime/identity/config.js +58 -0
- package/dist/mcp/server/runtime/identity/directory.js +87 -0
- package/dist/mcp/server/runtime/identity/liveness.js +32 -0
- package/dist/mcp/server/runtime/identity/names.js +74 -0
- package/dist/mcp/server/runtime/identity/public.js +19 -0
- package/dist/mcp/server/runtime/identity/state.js +32 -0
- package/dist/mcp/server/runtime/identity.js +96 -0
- package/dist/mcp/server/runtime/messages.js +160 -0
- package/dist/mcp/server/runtime/presence.js +52 -0
- package/dist/mcp/server/runtime/room-api.js +42 -0
- package/dist/mcp/server/runtime/room-state.js +122 -0
- package/dist/mcp/server/runtime/rooms.js +318 -0
- package/dist/mcp/server/runtime.js +14 -0
- package/dist/mcp/server/tools/agent-sessions.js +374 -0
- package/dist/mcp/server/tools/messages/index.js +14 -0
- package/dist/mcp/server/tools/messages/read-tool.js +176 -0
- package/dist/mcp/server/tools/messages/reasoning-tool.js +179 -0
- package/dist/mcp/server/tools/messages/response.js +17 -0
- package/dist/mcp/server/tools/messages/send-tool.js +203 -0
- package/dist/mcp/server/tools/messages/status-tool.js +87 -0
- package/dist/mcp/server/tools/messages/wait-tool.js +333 -0
- package/dist/mcp/server/tools/messages.js +1 -0
- package/dist/mcp/server/tools/onboarding/device-auth-tools.js +148 -0
- package/dist/mcp/server/tools/onboarding/name-tool.js +89 -0
- package/dist/mcp/server/tools/onboarding/responses.js +10 -0
- package/dist/mcp/server/tools/onboarding/status-tool.js +108 -0
- package/dist/mcp/server/tools/onboarding.js +8 -0
- package/dist/mcp/server/tools/rental/activity-tools.js +43 -0
- package/dist/mcp/server/tools/rental/context-tools.js +35 -0
- package/dist/mcp/server/tools/rental/index.js +17 -0
- package/dist/mcp/server/tools/rental/metering-tools.js +39 -0
- package/dist/mcp/server/tools/rental/patch-tools.js +53 -0
- package/dist/mcp/server/tools/rental/provider-tools.js +44 -0
- package/dist/mcp/server/tools/rental/response.js +10 -0
- package/dist/mcp/server/tools/rental/types.js +1 -0
- package/dist/mcp/server/tools/rental.js +1 -0
- package/dist/mcp/server/tools/rooms/index.js +5 -0
- package/dist/mcp/server/tools/rooms/inspection-tools.js +124 -0
- package/dist/mcp/server/tools/rooms/join-tools.js +37 -0
- package/dist/mcp/server/tools/rooms/repo-initialization-tool.js +113 -0
- package/dist/mcp/server/tools/rooms/repo-visibility-tool.js +20 -0
- package/dist/mcp/server/tools/rooms/response.js +10 -0
- package/dist/mcp/server/tools/rooms/resume-tool.js +48 -0
- package/dist/mcp/server/tools/rooms.js +1 -0
- package/dist/mcp/server/tools/tasks/api.js +222 -0
- package/dist/mcp/server/tools/tasks/artifact-tools.js +75 -0
- package/dist/mcp/server/tools/tasks/board-intent-tools.js +321 -0
- package/dist/mcp/server/tools/tasks/board-tools.js +73 -0
- package/dist/mcp/server/tools/tasks/context.js +35 -0
- package/dist/mcp/server/tools/tasks/event-tools.js +40 -0
- package/dist/mcp/server/tools/tasks/index.js +14 -0
- package/dist/mcp/server/tools/tasks/lease-tools.js +145 -0
- package/dist/mcp/server/tools/tasks/mutation-tools.js +121 -0
- package/dist/mcp/server/tools/tasks/response.js +13 -0
- package/dist/mcp/server/tools/tasks/schemas.js +68 -0
- package/dist/mcp/server/tools/tasks.js +1 -0
- package/dist/mcp/server.js +10 -2540
- package/dist/mcp/sse-client.js +22 -16
- package/dist/shared/activation-routing.js +263 -0
- package/dist/shared/agent-identity.js +21 -5
- package/dist/shared/agent-presence.js +24 -0
- package/dist/shared/handoff.js +145 -0
- package/dist/shared/rental/lrt.js +214 -0
- package/dist/shared/rental/meter-types.js +14 -0
- package/dist/shared/request-headers.js +3 -0
- package/dist/shared/room-agent-activity.js +41 -0
- package/dist/shared/room-agent-prompts.js +14 -1
- package/package.json +15 -5
- /package/dist/{mcp â shared}/codenames.js +0 -0
package/dist/mcp/server.js
CHANGED
|
@@ -1,2558 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { McpServer
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { userInfo } from "os";
|
|
8
|
-
import { join, dirname } from "path";
|
|
9
|
-
import { execSync } from "child_process";
|
|
10
|
-
import { SseClient } from "./sse-client.js";
|
|
11
|
-
import { getRoomFromConfig } from "./config-reader.js";
|
|
12
|
-
import { getGitRemoteIdentity } from "./git-remote.js";
|
|
13
|
-
import { AGENT_CODENAME_SPACE, normalizeSlugSegment, normalizeAgentBaseName, pickLocalCodename, } from "./codenames.js";
|
|
14
|
-
import { clearPendingDeviceAuth, clearStoredAuth, getCurrentCodexLiveSession, getLocalStatePath, getPendingDeviceAuth, getStoredAgentIdentity, getStoredAuth, getStoredCurrentRoom, getStoredRoomSession, listStoredCodexLiveSessions, saveRoomSession, setStoredAgentIdentity, setPendingDeviceAuth, setStoredAuth, touchRoomSession, updateLocalState, } from "./local-state.js";
|
|
15
|
-
import { encodeRoomIdPath, getCanonicalRoomWebPath, looksLikeInviteCode, normalizeInviteCode, } from "./room-id.js";
|
|
16
|
-
import { buildAgentActorLabel, formatOwnerAttribution, inferAgentIdeLabel, toTitleCaseCodename, } from "../shared/agent-identity.js";
|
|
17
|
-
import { buildRoomAgentPrompt, normalizeAgentPromptKind, } from "../shared/room-agent-prompts.js";
|
|
18
|
-
import { inspectLocalCodexSession, startLocalCodexSession, stopLocalCodexSession, toPublicCodexLiveSession, } from "./codex-session.js";
|
|
19
|
-
import { classifyPresenceStatusText, deriveTaskPresenceStatus, getRoomIdentityPresenceCacheKey, } from "./agent-presence.js";
|
|
20
|
-
import { buildRoomEventsQueryString } from "./room-events-query.js";
|
|
21
|
-
import { getPollTimeoutCapMs } from "../shared/poll-timeout-cap.js";
|
|
22
|
-
let currentRoom = null;
|
|
23
|
-
let currentAgentIdentityKey = "";
|
|
24
|
-
let currentAgentIdentity = null;
|
|
25
|
-
let currentAuthenticatedAccount = undefined;
|
|
26
|
-
const roomPresenceByIdentity = new Map();
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// Conversation-scoped identity (Option C: per-conversation hints)
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
const MAX_CONVERSATION_IDENTITIES = 20;
|
|
31
|
-
const conversationIdentities = new Map();
|
|
32
|
-
/**
|
|
33
|
-
* Get or set a conversation-scoped identity override.
|
|
34
|
-
* Falls back to the global `currentAgentIdentity` when conversationId is absent.
|
|
35
|
-
*/
|
|
36
|
-
function getConversationIdentity(conversationId) {
|
|
37
|
-
if (!conversationId)
|
|
38
|
-
return currentAgentIdentity;
|
|
39
|
-
return conversationIdentities.get(conversationId) ?? currentAgentIdentity;
|
|
40
|
-
}
|
|
41
|
-
function setConversationIdentity(conversationId, identity) {
|
|
42
|
-
// LRU-style eviction: if at cap, remove oldest entry
|
|
43
|
-
if (!conversationIdentities.has(conversationId) &&
|
|
44
|
-
conversationIdentities.size >= MAX_CONVERSATION_IDENTITIES) {
|
|
45
|
-
const oldestKey = conversationIdentities.keys().next().value;
|
|
46
|
-
if (oldestKey !== undefined)
|
|
47
|
-
conversationIdentities.delete(oldestKey);
|
|
48
|
-
}
|
|
49
|
-
conversationIdentities.set(conversationId, identity);
|
|
50
|
-
}
|
|
51
|
-
let currentAuthenticatedAccountSource = null;
|
|
52
|
-
let currentAuthenticatedEnvToken = null;
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
// Config
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
const API_URL = (process.env.LETAGENTS_API_URL || "http://localhost:3001").replace(/\/+$/, "");
|
|
57
|
-
const AGENT_NAME = (process.env.LETAGENTS_AGENT_NAME || process.env.AGENT_NAME || "").trim();
|
|
58
|
-
const AGENT_DISPLAY_NAME = (process.env.LETAGENTS_AGENT_DISPLAY_NAME || "").trim();
|
|
59
|
-
const AGENT_IDE_LABEL = (process.env.LETAGENTS_AGENT_IDE || process.env.AGENT_IDE || "").trim();
|
|
60
|
-
const AGENT_OWNER_LABEL = (process.env.LETAGENTS_AGENT_OWNER_LABEL || "").trim();
|
|
61
|
-
const EXPLICIT_AGENT_IDENTITY_KEY = getExplicitAgentIdentityStorageKey();
|
|
62
|
-
const AGENT_INSTANCE_UUID = randomUUID();
|
|
63
|
-
currentAgentIdentityKey =
|
|
64
|
-
EXPLICIT_AGENT_IDENTITY_KEY ?? `instance:${AGENT_INSTANCE_UUID}`;
|
|
65
|
-
currentAgentIdentity = getStoredAgentIdentity(currentAgentIdentityKey);
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// Helpers
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
function readCommandOutput(command, cwd = process.cwd()) {
|
|
70
|
-
try {
|
|
71
|
-
const output = execSync(command, {
|
|
72
|
-
cwd,
|
|
73
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
74
|
-
encoding: "utf-8",
|
|
75
|
-
}).trim();
|
|
76
|
-
return output || null;
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
// normalizeSlugSegment, normalizeAgentBaseName, AGENT_CODENAMES,
|
|
83
|
-
// AGENT_CODENAME_SPACE, hashStringToIndex, codenameFromIndex,
|
|
84
|
-
// pickLocalCodename â all imported from ./codenames.js
|
|
85
|
-
function isCodexRuntime() {
|
|
86
|
-
return Boolean(process.env.CODEX_THREAD_ID ||
|
|
87
|
-
process.env.CODEX_SHELL ||
|
|
88
|
-
process.env.CODEX_CI ||
|
|
89
|
-
process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE);
|
|
90
|
-
}
|
|
91
|
-
const AGENT_IDENTITY_SLOT_SUFFIX = ":slot:";
|
|
92
|
-
function getExplicitAgentIdentityStorageKey() {
|
|
93
|
-
const runtimeSignals = [
|
|
94
|
-
process.env.LETAGENTS_AGENT_INSTANCE_ID,
|
|
95
|
-
process.env.CODEX_THREAD_ID && `codex:${process.env.CODEX_THREAD_ID}`,
|
|
96
|
-
process.env.ANTIGRAVITY_THREAD_ID && `antigravity:${process.env.ANTIGRAVITY_THREAD_ID}`,
|
|
97
|
-
process.env.CLAUDECODE_SESSION_ID && `claude:${process.env.CLAUDECODE_SESSION_ID}`,
|
|
98
|
-
process.env.MCP_SESSION_ID && `mcp:${process.env.MCP_SESSION_ID}`,
|
|
99
|
-
].filter((value) => Boolean(value?.trim()));
|
|
100
|
-
if (runtimeSignals.length) {
|
|
101
|
-
return runtimeSignals[0];
|
|
102
|
-
}
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
function getFallbackAgentIdentityNamespaceKey(ideLabel) {
|
|
106
|
-
return `cwd:${process.cwd()}:ide:${normalizeAgentBaseName(ideLabel)}`;
|
|
107
|
-
}
|
|
108
|
-
function detectAgentIdeLabel() {
|
|
109
|
-
if (AGENT_IDE_LABEL) {
|
|
110
|
-
return toTitleCaseCodename(AGENT_IDE_LABEL);
|
|
111
|
-
}
|
|
112
|
-
if (isCodexRuntime()) {
|
|
113
|
-
return "Codex";
|
|
114
|
-
}
|
|
115
|
-
const explicitName = normalizeAgentBaseName(AGENT_NAME || AGENT_DISPLAY_NAME);
|
|
116
|
-
const inferred = inferAgentIdeLabel(explicitName);
|
|
117
|
-
return inferred || "Agent";
|
|
118
|
-
}
|
|
119
|
-
function getFallbackSlotPrefix(namespaceKey) {
|
|
120
|
-
return `${namespaceKey}${AGENT_IDENTITY_SLOT_SUFFIX}`;
|
|
121
|
-
}
|
|
122
|
-
function getFallbackSlotKey(namespaceKey, slotIndex) {
|
|
123
|
-
return `${getFallbackSlotPrefix(namespaceKey)}${slotIndex}`;
|
|
124
|
-
}
|
|
125
|
-
function parseFallbackSlotIndex(identityKey, namespaceKey) {
|
|
126
|
-
const prefix = getFallbackSlotPrefix(namespaceKey);
|
|
127
|
-
if (!identityKey.startsWith(prefix)) {
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
const slotIndex = Number.parseInt(identityKey.slice(prefix.length), 10);
|
|
131
|
-
return Number.isInteger(slotIndex) && slotIndex >= 0 ? slotIndex : null;
|
|
132
|
-
}
|
|
133
|
-
function isProcessAlive(pid) {
|
|
134
|
-
if (!pid || !Number.isInteger(pid) || pid <= 0) {
|
|
135
|
-
return false;
|
|
136
|
-
}
|
|
137
|
-
try {
|
|
138
|
-
process.kill(pid, 0);
|
|
139
|
-
return true;
|
|
140
|
-
}
|
|
141
|
-
catch (error) {
|
|
142
|
-
const err = error;
|
|
143
|
-
return err.code === "EPERM";
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
function claimFallbackIdentityKey(namespaceKey) {
|
|
147
|
-
let claimedKey = getFallbackSlotKey(namespaceKey, 0);
|
|
148
|
-
let claimedIdentity = null;
|
|
149
|
-
const now = new Date().toISOString();
|
|
150
|
-
updateLocalState((state) => {
|
|
151
|
-
const identities = state.agent_identities ?? {};
|
|
152
|
-
const leases = state.agent_identity_leases ?? {};
|
|
153
|
-
const namespacePrefix = getFallbackSlotPrefix(namespaceKey);
|
|
154
|
-
for (const [identityKey, lease] of Object.entries(leases)) {
|
|
155
|
-
if (!identityKey.startsWith(namespacePrefix) || lease.namespace_key !== namespaceKey) {
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
if (!isProcessAlive(lease.pid)) {
|
|
159
|
-
delete leases[identityKey];
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
const activeLeaseForPid = Object.entries(leases).find(([identityKey, lease]) => identityKey.startsWith(namespacePrefix) &&
|
|
163
|
-
lease.namespace_key === namespaceKey &&
|
|
164
|
-
lease.pid === process.pid);
|
|
165
|
-
if (activeLeaseForPid) {
|
|
166
|
-
const [identityKey, lease] = activeLeaseForPid;
|
|
167
|
-
lease.updated_at = now;
|
|
168
|
-
claimedKey = identityKey;
|
|
169
|
-
claimedIdentity = identities[identityKey] ?? null;
|
|
170
|
-
state.agent_identity_leases = leases;
|
|
171
|
-
return state;
|
|
172
|
-
}
|
|
173
|
-
const slotKeys = new Set();
|
|
174
|
-
for (const identityKey of Object.keys(identities)) {
|
|
175
|
-
if (identityKey.startsWith(namespacePrefix)) {
|
|
176
|
-
slotKeys.add(identityKey);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
for (const identityKey of Object.keys(leases)) {
|
|
180
|
-
if (identityKey.startsWith(namespacePrefix)) {
|
|
181
|
-
slotKeys.add(identityKey);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
const sortedSlotKeys = [...slotKeys].sort((left, right) => {
|
|
185
|
-
const leftIndex = parseFallbackSlotIndex(left, namespaceKey) ?? Number.MAX_SAFE_INTEGER;
|
|
186
|
-
const rightIndex = parseFallbackSlotIndex(right, namespaceKey) ?? Number.MAX_SAFE_INTEGER;
|
|
187
|
-
return leftIndex - rightIndex;
|
|
188
|
-
});
|
|
189
|
-
for (const identityKey of sortedSlotKeys) {
|
|
190
|
-
if (leases[identityKey]) {
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
leases[identityKey] = {
|
|
194
|
-
namespace_key: namespaceKey,
|
|
195
|
-
pid: process.pid,
|
|
196
|
-
acquired_at: now,
|
|
197
|
-
updated_at: now,
|
|
198
|
-
};
|
|
199
|
-
claimedKey = identityKey;
|
|
200
|
-
claimedIdentity = identities[identityKey] ?? null;
|
|
201
|
-
state.agent_identity_leases = leases;
|
|
202
|
-
return state;
|
|
203
|
-
}
|
|
204
|
-
let nextSlotIndex = 0;
|
|
205
|
-
while (slotKeys.has(getFallbackSlotKey(namespaceKey, nextSlotIndex))) {
|
|
206
|
-
nextSlotIndex += 1;
|
|
207
|
-
}
|
|
208
|
-
claimedKey = getFallbackSlotKey(namespaceKey, nextSlotIndex);
|
|
209
|
-
leases[claimedKey] = {
|
|
210
|
-
namespace_key: namespaceKey,
|
|
211
|
-
pid: process.pid,
|
|
212
|
-
acquired_at: now,
|
|
213
|
-
updated_at: now,
|
|
214
|
-
};
|
|
215
|
-
claimedIdentity = identities[claimedKey] ?? null;
|
|
216
|
-
state.agent_identity_leases = leases;
|
|
217
|
-
return state;
|
|
218
|
-
});
|
|
219
|
-
return {
|
|
220
|
-
identityKey: claimedKey,
|
|
221
|
-
identity: claimedIdentity,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
function ensureAgentIdentityKey(ideLabel) {
|
|
225
|
-
if (EXPLICIT_AGENT_IDENTITY_KEY) {
|
|
226
|
-
currentAgentIdentityKey = EXPLICIT_AGENT_IDENTITY_KEY;
|
|
227
|
-
currentAgentIdentity = getStoredAgentIdentity(currentAgentIdentityKey);
|
|
228
|
-
return currentAgentIdentityKey;
|
|
229
|
-
}
|
|
230
|
-
// v1: Use per-instance UUID for guaranteed distinct identity.
|
|
231
|
-
// Each MCP process gets a unique UUID â no slot/PID coordination needed.
|
|
232
|
-
// Trade-off: process restart = new identity (accepted for v1).
|
|
233
|
-
const uuidKey = `instance:${AGENT_INSTANCE_UUID}`;
|
|
234
|
-
currentAgentIdentityKey = uuidKey;
|
|
235
|
-
currentAgentIdentity = getStoredAgentIdentity(currentAgentIdentityKey);
|
|
236
|
-
return currentAgentIdentityKey;
|
|
237
|
-
}
|
|
238
|
-
async function getAuthenticatedAgentDirectory() {
|
|
239
|
-
try {
|
|
240
|
-
const result = await apiCall("/agents/me");
|
|
241
|
-
const account = result?.account;
|
|
242
|
-
if (!account?.login?.trim()) {
|
|
243
|
-
return null;
|
|
244
|
-
}
|
|
245
|
-
currentAuthenticatedAccount = account;
|
|
246
|
-
currentAuthenticatedAccountSource = process.env.LETAGENTS_TOKEN?.trim() ? "env" : "stored";
|
|
247
|
-
currentAuthenticatedEnvToken = process.env.LETAGENTS_TOKEN?.trim() || null;
|
|
248
|
-
return {
|
|
249
|
-
account,
|
|
250
|
-
agents: Array.isArray(result?.agents) ? result.agents : [],
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
catch {
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
function shouldReuseStoredIdentity(identity, identityKey) {
|
|
258
|
-
return Boolean(identity &&
|
|
259
|
-
identity.runtime_key === identityKey &&
|
|
260
|
-
identity.display_name?.trim() &&
|
|
261
|
-
identity.ide_label?.trim() &&
|
|
262
|
-
identity.owner_attribution?.trim());
|
|
263
|
-
}
|
|
264
|
-
function resolveExplicitAgentIdentity() {
|
|
265
|
-
if (AGENT_NAME) {
|
|
266
|
-
const name = normalizeAgentBaseName(AGENT_NAME);
|
|
267
|
-
return {
|
|
268
|
-
name,
|
|
269
|
-
display_name: AGENT_DISPLAY_NAME || toTitleCaseCodename(AGENT_NAME),
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
if (AGENT_DISPLAY_NAME) {
|
|
273
|
-
return {
|
|
274
|
-
name: normalizeAgentBaseName(AGENT_DISPLAY_NAME),
|
|
275
|
-
display_name: AGENT_DISPLAY_NAME.trim(),
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
// pickLocalCodename â imported from ./codenames.js
|
|
281
|
-
async function resolveAgentName(authAvailable, identityKey) {
|
|
282
|
-
const explicit = resolveExplicitAgentIdentity();
|
|
283
|
-
if (explicit) {
|
|
284
|
-
return explicit;
|
|
285
|
-
}
|
|
286
|
-
if (shouldReuseStoredIdentity(currentAgentIdentity, identityKey)) {
|
|
287
|
-
return {
|
|
288
|
-
name: currentAgentIdentity.name,
|
|
289
|
-
display_name: currentAgentIdentity.display_name,
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
if (!authAvailable) {
|
|
293
|
-
return pickLocalCodename(identityKey);
|
|
294
|
-
}
|
|
295
|
-
const directory = await getAuthenticatedAgentDirectory();
|
|
296
|
-
const existingNames = new Set((directory?.agents ?? [])
|
|
297
|
-
.map((agent) => normalizeAgentBaseName(agent.name || ""))
|
|
298
|
-
.filter(Boolean));
|
|
299
|
-
for (let offset = 0; offset < AGENT_CODENAME_SPACE; offset += 1) {
|
|
300
|
-
const candidate = pickLocalCodename(identityKey, offset);
|
|
301
|
-
if (!existingNames.has(candidate.name)) {
|
|
302
|
-
return candidate;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
const fallbackHash = createHash("sha256")
|
|
306
|
-
.update(identityKey)
|
|
307
|
-
.digest("hex")
|
|
308
|
-
.slice(0, 4);
|
|
309
|
-
const fallback = pickLocalCodename(identityKey);
|
|
310
|
-
return {
|
|
311
|
-
name: `${fallback.name}-${fallbackHash}`,
|
|
312
|
-
display_name: `${fallback.display_name} ${fallbackHash.toUpperCase()}`,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
async function getAuthenticatedAccountProfile() {
|
|
316
|
-
const envToken = (process.env.LETAGENTS_TOKEN || "").trim();
|
|
317
|
-
if (envToken) {
|
|
318
|
-
if (currentAuthenticatedAccountSource === "env" &&
|
|
319
|
-
currentAuthenticatedEnvToken === envToken &&
|
|
320
|
-
currentAuthenticatedAccount?.login?.trim()) {
|
|
321
|
-
return currentAuthenticatedAccount;
|
|
322
|
-
}
|
|
323
|
-
const directory = await getAuthenticatedAgentDirectory();
|
|
324
|
-
if (directory?.account?.login?.trim()) {
|
|
325
|
-
return directory.account;
|
|
326
|
-
}
|
|
327
|
-
return null;
|
|
328
|
-
}
|
|
329
|
-
const storedAccount = getStoredAuth()?.account;
|
|
330
|
-
if (storedAccount?.login?.trim()) {
|
|
331
|
-
currentAuthenticatedAccount = storedAccount;
|
|
332
|
-
currentAuthenticatedAccountSource = "stored";
|
|
333
|
-
currentAuthenticatedEnvToken = null;
|
|
334
|
-
return storedAccount;
|
|
335
|
-
}
|
|
336
|
-
if (!getLetagentsToken()) {
|
|
337
|
-
currentAuthenticatedAccount = undefined;
|
|
338
|
-
currentAuthenticatedAccountSource = null;
|
|
339
|
-
currentAuthenticatedEnvToken = null;
|
|
340
|
-
return null;
|
|
341
|
-
}
|
|
342
|
-
if (currentAuthenticatedAccountSource === "stored" &&
|
|
343
|
-
currentAuthenticatedAccount?.login?.trim()) {
|
|
344
|
-
return currentAuthenticatedAccount;
|
|
345
|
-
}
|
|
346
|
-
const directory = await getAuthenticatedAgentDirectory();
|
|
347
|
-
if (directory?.account?.login?.trim()) {
|
|
348
|
-
return directory.account;
|
|
349
|
-
}
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
async function resolveOwnerContext() {
|
|
353
|
-
const account = await getAuthenticatedAccountProfile();
|
|
354
|
-
const authLogin = account?.login?.trim() || null;
|
|
355
|
-
const authLabel = account?.display_name?.trim() || authLogin;
|
|
356
|
-
if (authLogin || authLabel || AGENT_OWNER_LABEL) {
|
|
357
|
-
const label = AGENT_OWNER_LABEL || authLabel || authLogin || "Owner";
|
|
358
|
-
const slug = normalizeSlugSegment(authLogin || label, "owner");
|
|
359
|
-
return { slug, label, login: authLogin };
|
|
360
|
-
}
|
|
361
|
-
const gitUserName = readCommandOutput("git config --get user.name");
|
|
362
|
-
const gitUserEmail = readCommandOutput("git config --get user.email");
|
|
363
|
-
const gitIdentity = gitUserName || gitUserEmail?.split("@")[0] || null;
|
|
364
|
-
if (gitIdentity) {
|
|
365
|
-
return {
|
|
366
|
-
slug: normalizeSlugSegment(gitIdentity, "owner"),
|
|
367
|
-
label: gitIdentity,
|
|
368
|
-
login: null,
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
const osIdentity = process.env.USER ||
|
|
372
|
-
process.env.LOGNAME ||
|
|
373
|
-
process.env.USERNAME ||
|
|
374
|
-
(() => {
|
|
375
|
-
try {
|
|
376
|
-
return userInfo().username;
|
|
377
|
-
}
|
|
378
|
-
catch {
|
|
379
|
-
return null;
|
|
380
|
-
}
|
|
381
|
-
})() ||
|
|
382
|
-
"owner";
|
|
383
|
-
return {
|
|
384
|
-
slug: normalizeSlugSegment(osIdentity, "owner"),
|
|
385
|
-
label: osIdentity,
|
|
386
|
-
login: null,
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
function sameAgentIdentity(left, right) {
|
|
390
|
-
return Boolean(left &&
|
|
391
|
-
left.name === right.name &&
|
|
392
|
-
left.display_name === right.display_name &&
|
|
393
|
-
left.owner_label === right.owner_label &&
|
|
394
|
-
left.owner_attribution === right.owner_attribution &&
|
|
395
|
-
left.ide_label === right.ide_label &&
|
|
396
|
-
left.actor_label === right.actor_label &&
|
|
397
|
-
left.canonical_key === right.canonical_key &&
|
|
398
|
-
left.runtime_key === right.runtime_key &&
|
|
399
|
-
left.source === right.source);
|
|
400
|
-
}
|
|
401
|
-
function toPublicAgentIdentity(identity) {
|
|
402
|
-
if (!identity) {
|
|
403
|
-
return null;
|
|
404
|
-
}
|
|
405
|
-
return {
|
|
406
|
-
name: identity.name,
|
|
407
|
-
display_name: identity.display_name,
|
|
408
|
-
owner_label: identity.owner_label,
|
|
409
|
-
owner_attribution: identity.owner_attribution ?? formatOwnerAttribution(identity.owner_label),
|
|
410
|
-
ide_label: identity.ide_label ?? inferAgentIdeLabel(identity.display_name) ?? "Agent",
|
|
411
|
-
actor_label: identity.actor_label,
|
|
412
|
-
canonical_key: identity.canonical_key ?? null,
|
|
413
|
-
runtime_key: identity.runtime_key ?? null,
|
|
414
|
-
agent_instance_id: AGENT_INSTANCE_UUID,
|
|
415
|
-
source: identity.source,
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
async function ensureAgentIdentity() {
|
|
419
|
-
const owner = await resolveOwnerContext();
|
|
420
|
-
const authAvailable = Boolean(getLetagentsToken());
|
|
421
|
-
const ideLabel = detectAgentIdeLabel();
|
|
422
|
-
const identityKey = ensureAgentIdentityKey(ideLabel);
|
|
423
|
-
const ownerAttribution = formatOwnerAttribution(owner.label);
|
|
424
|
-
const { name, display_name: displayName } = await resolveAgentName(authAvailable, identityKey);
|
|
425
|
-
const actorLabel = buildAgentActorLabel({
|
|
426
|
-
display_name: displayName,
|
|
427
|
-
owner_label: owner.label,
|
|
428
|
-
ide_label: ideLabel,
|
|
429
|
-
});
|
|
430
|
-
let resolved = {
|
|
431
|
-
name,
|
|
432
|
-
display_name: displayName,
|
|
433
|
-
owner_label: owner.label,
|
|
434
|
-
owner_attribution: ownerAttribution,
|
|
435
|
-
ide_label: ideLabel,
|
|
436
|
-
actor_label: actorLabel,
|
|
437
|
-
canonical_key: owner.login ? `${owner.login}/${name}` : null,
|
|
438
|
-
runtime_key: identityKey,
|
|
439
|
-
source: "local",
|
|
440
|
-
resolved_at: new Date().toISOString(),
|
|
441
|
-
};
|
|
442
|
-
if (currentAgentIdentity &&
|
|
443
|
-
currentAgentIdentity.name === resolved.name &&
|
|
444
|
-
currentAgentIdentity.display_name === resolved.display_name &&
|
|
445
|
-
currentAgentIdentity.owner_label === resolved.owner_label &&
|
|
446
|
-
currentAgentIdentity.owner_attribution === resolved.owner_attribution &&
|
|
447
|
-
currentAgentIdentity.ide_label === resolved.ide_label &&
|
|
448
|
-
currentAgentIdentity.actor_label === resolved.actor_label &&
|
|
449
|
-
currentAgentIdentity.runtime_key === resolved.runtime_key &&
|
|
450
|
-
(!authAvailable || currentAgentIdentity.source === "api")) {
|
|
451
|
-
return currentAgentIdentity;
|
|
452
|
-
}
|
|
453
|
-
if (authAvailable) {
|
|
454
|
-
try {
|
|
455
|
-
const registered = await apiCall("/agents", {
|
|
456
|
-
method: "POST",
|
|
457
|
-
body: JSON.stringify({
|
|
458
|
-
name: resolved.name,
|
|
459
|
-
display_name: resolved.display_name,
|
|
460
|
-
owner_label: resolved.owner_label,
|
|
461
|
-
}),
|
|
462
|
-
});
|
|
463
|
-
resolved = {
|
|
464
|
-
...resolved,
|
|
465
|
-
canonical_key: typeof registered.canonical_key === "string"
|
|
466
|
-
? registered.canonical_key
|
|
467
|
-
: resolved.canonical_key,
|
|
468
|
-
display_name: typeof registered.display_name === "string"
|
|
469
|
-
? registered.display_name
|
|
470
|
-
: resolved.display_name,
|
|
471
|
-
owner_label: typeof registered.owner_label === "string"
|
|
472
|
-
? registered.owner_label
|
|
473
|
-
: resolved.owner_label,
|
|
474
|
-
source: "api",
|
|
475
|
-
};
|
|
476
|
-
resolved.owner_attribution = formatOwnerAttribution(resolved.owner_label);
|
|
477
|
-
resolved.actor_label = buildAgentActorLabel({
|
|
478
|
-
display_name: resolved.display_name,
|
|
479
|
-
owner_label: resolved.owner_label,
|
|
480
|
-
ide_label: resolved.ide_label,
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
catch (error) {
|
|
484
|
-
console.error("Agent identity registration failed:", error instanceof Error ? error.message : error);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
if (!sameAgentIdentity(currentAgentIdentity, resolved)) {
|
|
488
|
-
currentAgentIdentity = setStoredAgentIdentity({
|
|
489
|
-
...resolved,
|
|
490
|
-
resolved_at: new Date().toISOString(),
|
|
491
|
-
}, identityKey);
|
|
492
|
-
}
|
|
493
|
-
return currentAgentIdentity ?? resolved;
|
|
494
|
-
}
|
|
495
|
-
/**
|
|
496
|
-
* Resolve the root of the git repository containing `dir`.
|
|
497
|
-
* Returns null if `dir` is not inside a git repo.
|
|
498
|
-
*/
|
|
499
|
-
function resolveGitRoot(dir) {
|
|
500
|
-
try {
|
|
501
|
-
const root = execSync("git rev-parse --show-toplevel", {
|
|
502
|
-
cwd: dir,
|
|
503
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
504
|
-
encoding: "utf-8",
|
|
505
|
-
}).trim();
|
|
506
|
-
return root || null;
|
|
507
|
-
}
|
|
508
|
-
catch {
|
|
509
|
-
return null;
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
/**
|
|
513
|
-
* Walk from `startDir` up to the filesystem root, returning the first
|
|
514
|
-
* directory that contains `.letagents.json`, or null if none found.
|
|
515
|
-
*/
|
|
516
|
-
function findExistingConfig(startDir) {
|
|
517
|
-
let current = startDir;
|
|
518
|
-
while (true) {
|
|
519
|
-
if (existsSync(join(current, ".letagents.json")))
|
|
520
|
-
return current;
|
|
521
|
-
const parent = dirname(current);
|
|
522
|
-
if (parent === current)
|
|
523
|
-
break; // reached fs root
|
|
524
|
-
current = parent;
|
|
525
|
-
}
|
|
526
|
-
return null;
|
|
527
|
-
}
|
|
528
|
-
class ApiError extends Error {
|
|
529
|
-
status;
|
|
530
|
-
body;
|
|
531
|
-
constructor(status, body) {
|
|
532
|
-
super(`API error ${status}: ${body}`);
|
|
533
|
-
this.name = "ApiError";
|
|
534
|
-
this.status = status;
|
|
535
|
-
this.body = body;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
class RepoRoomAuthRequiredError extends Error {
|
|
539
|
-
roomId;
|
|
540
|
-
pendingAuth;
|
|
541
|
-
constructor(roomId, pendingAuth) {
|
|
542
|
-
super(`Repo room '${roomId}' requires authentication. Device flow started: open ${pendingAuth.verification_uri} and enter code ${pendingAuth.user_code}, then run poll_device_auth.`);
|
|
543
|
-
this.name = "RepoRoomAuthRequiredError";
|
|
544
|
-
this.roomId = roomId;
|
|
545
|
-
this.pendingAuth = pendingAuth;
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
function getLetagentsToken() {
|
|
549
|
-
return process.env.LETAGENTS_TOKEN || getStoredAuth()?.token || "";
|
|
550
|
-
}
|
|
551
|
-
function getAuthorizationHeader() {
|
|
552
|
-
const letagentsToken = getLetagentsToken();
|
|
553
|
-
if (letagentsToken) {
|
|
554
|
-
return `Bearer ${letagentsToken}`;
|
|
555
|
-
}
|
|
556
|
-
return null;
|
|
557
|
-
}
|
|
558
|
-
function isMissingRouteError(error) {
|
|
559
|
-
return (error instanceof ApiError &&
|
|
560
|
-
(error.status === 404 || error.status === 405) &&
|
|
561
|
-
/Cannot (GET|POST|PATCH)|Not Found|Cannot GET \/rooms|Cannot POST \/rooms/i.test(error.body));
|
|
562
|
-
}
|
|
563
|
-
function parseApiErrorPayload(error) {
|
|
564
|
-
if (!(error instanceof ApiError)) {
|
|
565
|
-
return null;
|
|
566
|
-
}
|
|
567
|
-
try {
|
|
568
|
-
const parsed = JSON.parse(error.body);
|
|
569
|
-
return parsed && typeof parsed === "object" ? parsed : null;
|
|
570
|
-
}
|
|
571
|
-
catch {
|
|
572
|
-
return null;
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
function resolveApiPath(urlOrPath) {
|
|
576
|
-
if (!urlOrPath) {
|
|
577
|
-
return "/auth/device/start";
|
|
578
|
-
}
|
|
579
|
-
try {
|
|
580
|
-
const parsed = new URL(urlOrPath, `${API_URL}/`);
|
|
581
|
-
const apiBase = new URL(`${API_URL}/`);
|
|
582
|
-
if (parsed.origin !== apiBase.origin) {
|
|
583
|
-
return "/auth/device/start";
|
|
584
|
-
}
|
|
585
|
-
return `${parsed.pathname}${parsed.search}`;
|
|
586
|
-
}
|
|
587
|
-
catch {
|
|
588
|
-
return "/auth/device/start";
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
async function apiCall(path, options) {
|
|
592
|
-
const headers = {
|
|
593
|
-
"Content-Type": "application/json",
|
|
594
|
-
...options?.headers,
|
|
595
|
-
};
|
|
596
|
-
const authorizationHeader = getAuthorizationHeader();
|
|
597
|
-
if (authorizationHeader && !headers.Authorization) {
|
|
598
|
-
headers.Authorization = authorizationHeader;
|
|
599
|
-
}
|
|
600
|
-
const res = await fetch(`${API_URL}${path}`, {
|
|
601
|
-
...options,
|
|
602
|
-
headers,
|
|
603
|
-
});
|
|
604
|
-
if (!res.ok) {
|
|
605
|
-
const body = await res.text();
|
|
606
|
-
if (res.status === 401) {
|
|
607
|
-
// Only clear on 401 (invalid/expired credential), NOT on 403
|
|
608
|
-
// (valid credential but insufficient permissions, e.g., private repo access)
|
|
609
|
-
clearStoredAuth();
|
|
610
|
-
currentAuthenticatedAccount = undefined;
|
|
611
|
-
currentAuthenticatedAccountSource = null;
|
|
612
|
-
currentAuthenticatedEnvToken = null;
|
|
613
|
-
}
|
|
614
|
-
throw new ApiError(res.status, body);
|
|
615
|
-
}
|
|
616
|
-
const body = await res.text();
|
|
617
|
-
if (!body) {
|
|
618
|
-
return null;
|
|
619
|
-
}
|
|
620
|
-
return JSON.parse(body);
|
|
621
|
-
}
|
|
622
|
-
async function startPendingDeviceAuth(roomId, deviceFlowUrl) {
|
|
623
|
-
const existing = getPendingDeviceAuth();
|
|
624
|
-
if (existing?.suggested_room_id === roomId) {
|
|
625
|
-
return existing;
|
|
626
|
-
}
|
|
627
|
-
const response = await apiCall(resolveApiPath(deviceFlowUrl), {
|
|
628
|
-
method: "POST",
|
|
629
|
-
});
|
|
630
|
-
return setPendingDeviceAuth({
|
|
631
|
-
request_id: response.request_id,
|
|
632
|
-
user_code: response.user_code,
|
|
633
|
-
verification_uri: response.verification_uri,
|
|
634
|
-
interval_seconds: response.interval,
|
|
635
|
-
expires_at: new Date(Date.now() + response.expires_in * 1000).toISOString(),
|
|
636
|
-
started_at: new Date().toISOString(),
|
|
637
|
-
suggested_room_id: roomId,
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
async function maybeHandleRepoRoomAuthRequired(error, roomId) {
|
|
641
|
-
const payload = parseApiErrorPayload(error);
|
|
642
|
-
if (!(error instanceof ApiError) || error.status !== 401 || payload?.error !== "auth_required") {
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
645
|
-
const pendingAuth = await startPendingDeviceAuth(roomId, typeof payload.device_flow_url === "string" ? payload.device_flow_url : undefined);
|
|
646
|
-
throw new RepoRoomAuthRequiredError(roomId, pendingAuth);
|
|
647
|
-
}
|
|
648
|
-
function toRepoRoomAuthRequiredResult(error) {
|
|
649
|
-
return {
|
|
650
|
-
success: false,
|
|
651
|
-
error: "auth_required",
|
|
652
|
-
room_id: error.roomId,
|
|
653
|
-
next_step: "poll_device_auth",
|
|
654
|
-
pending_device_auth: error.pendingAuth,
|
|
655
|
-
message: error.message,
|
|
656
|
-
};
|
|
657
|
-
}
|
|
658
|
-
function toRoomState(input) {
|
|
659
|
-
return {
|
|
660
|
-
room_id: input.room_id,
|
|
661
|
-
project_id: input.project_id ?? null,
|
|
662
|
-
code: input.code ?? null,
|
|
663
|
-
display_name: input.display_name ?? null,
|
|
664
|
-
joined_via: input.joined_via,
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
function getCanonicalRoomWebUrl(roomId) {
|
|
668
|
-
return new URL(getCanonicalRoomWebPath(roomId), `${API_URL}/`).toString();
|
|
669
|
-
}
|
|
670
|
-
function withCanonicalRoomLink(roomId, payload) {
|
|
671
|
-
return {
|
|
672
|
-
...payload,
|
|
673
|
-
room_path: getCanonicalRoomWebPath(roomId),
|
|
674
|
-
room_url: getCanonicalRoomWebUrl(roomId),
|
|
675
|
-
};
|
|
676
|
-
}
|
|
677
|
-
function toPublicRoomState(state) {
|
|
678
|
-
if (!state) {
|
|
679
|
-
return null;
|
|
680
|
-
}
|
|
681
|
-
return withCanonicalRoomLink(state.room_id, {
|
|
682
|
-
room_id: state.room_id,
|
|
683
|
-
code: state.code ?? null,
|
|
684
|
-
display_name: state.display_name ?? null,
|
|
685
|
-
joined_via: state.joined_via,
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
|
-
function toPublicStoredRoomSession(session) {
|
|
689
|
-
if (!session) {
|
|
690
|
-
return null;
|
|
691
|
-
}
|
|
692
|
-
return withCanonicalRoomLink(session.room_id, {
|
|
693
|
-
room_id: session.room_id,
|
|
694
|
-
code: session.code ?? null,
|
|
695
|
-
display_name: session.display_name ?? null,
|
|
696
|
-
joined_via: session.joined_via,
|
|
697
|
-
joined_at: session.joined_at,
|
|
698
|
-
last_seen_at: session.last_seen_at,
|
|
699
|
-
last_message_id: session.last_message_id ?? null,
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
function toPublicRoomResponse(response, fallbackRoomId) {
|
|
703
|
-
const { id: _legacyId, project_id: _legacyProjectId, ...rest } = response;
|
|
704
|
-
return {
|
|
705
|
-
...withCanonicalRoomLink(typeof rest.room_id === "string" ? rest.room_id : fallbackRoomId, rest),
|
|
706
|
-
room_id: typeof rest.room_id === "string" ? rest.room_id : fallbackRoomId,
|
|
707
|
-
};
|
|
708
|
-
}
|
|
709
|
-
async function withAgentIdentity(payload) {
|
|
710
|
-
return {
|
|
711
|
-
...payload,
|
|
712
|
-
agent_identity: toPublicAgentIdentity(await ensureAgentIdentity()),
|
|
713
|
-
};
|
|
714
|
-
}
|
|
715
|
-
function rememberRoom(state, lastMessageId) {
|
|
716
|
-
currentRoom = state;
|
|
717
|
-
saveRoomSession({
|
|
718
|
-
room_id: state.room_id,
|
|
719
|
-
project_id: state.project_id ?? null,
|
|
720
|
-
code: state.code ?? null,
|
|
721
|
-
display_name: state.display_name ?? null,
|
|
722
|
-
joined_via: state.joined_via,
|
|
723
|
-
last_message_id: lastMessageId,
|
|
724
|
-
});
|
|
725
|
-
sseClient.unsubscribeAll();
|
|
726
|
-
sseClient.subscribe({
|
|
727
|
-
roomId: state.room_id,
|
|
728
|
-
projectId: state.project_id ?? null,
|
|
729
|
-
}, (_message) => {
|
|
730
|
-
touchRoomSession(state.room_id);
|
|
731
|
-
server.server.sendResourceListChanged();
|
|
732
|
-
});
|
|
733
|
-
return state;
|
|
734
|
-
}
|
|
735
|
-
function touchCurrentRoom(lastMessageId) {
|
|
736
|
-
if (!currentRoom) {
|
|
737
|
-
return;
|
|
738
|
-
}
|
|
739
|
-
touchRoomSession(currentRoom.room_id, lastMessageId);
|
|
740
|
-
}
|
|
741
|
-
function getRememberedRoomPresence(roomId, identity) {
|
|
742
|
-
if (!roomId || !identity) {
|
|
743
|
-
return { status: "idle", status_text: null };
|
|
744
|
-
}
|
|
745
|
-
return (roomPresenceByIdentity.get(getRoomIdentityPresenceCacheKey(roomId, identity.actor_label)) ?? { status: "idle", status_text: null });
|
|
746
|
-
}
|
|
747
|
-
async function syncRoomPresence(roomId, identity, presence) {
|
|
748
|
-
if (!roomId || !identity) {
|
|
749
|
-
return;
|
|
750
|
-
}
|
|
751
|
-
roomPresenceByIdentity.set(getRoomIdentityPresenceCacheKey(roomId, identity.actor_label), presence);
|
|
752
|
-
try {
|
|
753
|
-
await apiCall(`/rooms/${encodeRoomIdPath(roomId)}/presence`, {
|
|
754
|
-
method: "POST",
|
|
755
|
-
body: JSON.stringify({
|
|
756
|
-
actor_label: identity.actor_label,
|
|
757
|
-
agent_key: identity.canonical_key,
|
|
758
|
-
display_name: identity.display_name,
|
|
759
|
-
owner_label: identity.owner_label,
|
|
760
|
-
ide_label: identity.ide_label,
|
|
761
|
-
status: presence.status,
|
|
762
|
-
status_text: presence.status_text,
|
|
763
|
-
}),
|
|
764
|
-
});
|
|
765
|
-
touchRoomSession(roomId);
|
|
766
|
-
}
|
|
767
|
-
catch (error) {
|
|
768
|
-
if (isMissingRouteError(error)) {
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
throw error;
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
async function heartbeatRoomPresence(roomId, identity) {
|
|
775
|
-
await syncRoomPresence(roomId, identity, getRememberedRoomPresence(roomId, identity));
|
|
776
|
-
}
|
|
777
|
-
function getTargetRoomId(roomId) {
|
|
778
|
-
return roomId || currentRoom?.room_id || null;
|
|
779
|
-
}
|
|
780
|
-
function getFallbackProjectId() {
|
|
781
|
-
return currentRoom?.project_id ?? null;
|
|
782
|
-
}
|
|
783
|
-
function getLastMessageId(payload) {
|
|
784
|
-
if (!payload || typeof payload !== "object") {
|
|
785
|
-
return undefined;
|
|
786
|
-
}
|
|
787
|
-
const messages = payload.messages;
|
|
788
|
-
const lastMessage = messages?.at(-1);
|
|
789
|
-
return typeof lastMessage?.id === "string" ? lastMessage.id : undefined;
|
|
790
|
-
}
|
|
791
|
-
function withJoinRoomAgentPrompt(payload) {
|
|
792
|
-
return {
|
|
793
|
-
...payload,
|
|
794
|
-
agent_prompt_kind: "join",
|
|
795
|
-
agent_prompt: buildRoomAgentPrompt("join"),
|
|
796
|
-
};
|
|
797
|
-
}
|
|
798
|
-
function normalizeJoinSessionMode(value) {
|
|
799
|
-
return String(value || "").trim().toLowerCase() === "live" ? "live" : "current";
|
|
800
|
-
}
|
|
801
|
-
function getCurrentLiveSessionPayload(roomId) {
|
|
802
|
-
const session = getCurrentCodexLiveSession(roomId);
|
|
803
|
-
return session ? toPublicCodexLiveSession(session) : null;
|
|
804
|
-
}
|
|
805
|
-
function toAgentReadableMessage(message) {
|
|
806
|
-
if (!message || typeof message !== "object") {
|
|
807
|
-
return message;
|
|
808
|
-
}
|
|
809
|
-
const record = message;
|
|
810
|
-
const kind = normalizeAgentPromptKind(record.agent_prompt_kind);
|
|
811
|
-
const text = typeof record.text === "string" ? record.text : null;
|
|
812
|
-
if (!kind || text === null) {
|
|
813
|
-
return record;
|
|
814
|
-
}
|
|
815
|
-
return {
|
|
816
|
-
...record,
|
|
817
|
-
visible_text: text,
|
|
818
|
-
agent_prompt: buildRoomAgentPrompt(kind),
|
|
819
|
-
prompt_injected: kind === "inline",
|
|
820
|
-
};
|
|
821
|
-
}
|
|
822
|
-
function toAgentReadableMessages(messages) {
|
|
823
|
-
return (messages ?? []).map((message) => toAgentReadableMessage(message));
|
|
824
|
-
}
|
|
825
|
-
function appendIncludePromptOnly(path) {
|
|
826
|
-
return `${path}${path.includes("?") ? "&" : "?"}include_prompt_only=1`;
|
|
827
|
-
}
|
|
828
|
-
async function roomScopedApiCall(input) {
|
|
829
|
-
if (input.room_id) {
|
|
830
|
-
try {
|
|
831
|
-
const result = await apiCall(input.room_path(input.room_id), input.options);
|
|
832
|
-
touchRoomSession(input.room_id, getLastMessageId(result));
|
|
833
|
-
return result;
|
|
834
|
-
}
|
|
835
|
-
catch (error) {
|
|
836
|
-
await maybeHandleRepoRoomAuthRequired(error, input.room_id);
|
|
837
|
-
if (!input.project_id || !isMissingRouteError(error)) {
|
|
838
|
-
throw error;
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
if (!input.project_id) {
|
|
843
|
-
throw new Error("No room is available for this request.");
|
|
844
|
-
}
|
|
845
|
-
const result = await apiCall(input.project_path(input.project_id), input.options);
|
|
846
|
-
if (input.room_id) {
|
|
847
|
-
touchRoomSession(input.room_id, getLastMessageId(result));
|
|
848
|
-
}
|
|
849
|
-
return result;
|
|
850
|
-
}
|
|
851
|
-
async function joinRoomIdentifier(identifier, joinedVia) {
|
|
852
|
-
const roomId = joinedVia === "join_code" ? normalizeInviteCode(identifier) : identifier.trim();
|
|
853
|
-
try {
|
|
854
|
-
const response = await apiCall(`/rooms/${encodeRoomIdPath(roomId)}/join`, { method: "POST" });
|
|
855
|
-
const joinedRoomId = typeof response.room_id === "string"
|
|
856
|
-
? response.room_id
|
|
857
|
-
: roomId;
|
|
858
|
-
const room = rememberRoom(toRoomState({
|
|
859
|
-
room_id: joinedRoomId,
|
|
860
|
-
project_id: typeof response.project_id === "string" ? response.project_id : null,
|
|
861
|
-
code: typeof response.code === "string"
|
|
862
|
-
? response.code
|
|
863
|
-
: looksLikeInviteCode(joinedRoomId)
|
|
864
|
-
? joinedRoomId
|
|
865
|
-
: null,
|
|
866
|
-
display_name: typeof response.display_name === "string" ? response.display_name : null,
|
|
867
|
-
joined_via: joinedVia,
|
|
868
|
-
}));
|
|
869
|
-
const agentIdentity = await ensureAgentIdentity();
|
|
870
|
-
await syncRoomPresence(room.room_id, agentIdentity, {
|
|
871
|
-
status: "idle",
|
|
872
|
-
status_text: "online in room",
|
|
873
|
-
});
|
|
874
|
-
return {
|
|
875
|
-
room,
|
|
876
|
-
response: {
|
|
877
|
-
...response,
|
|
878
|
-
room_id: joinedRoomId,
|
|
879
|
-
agent_identity: toPublicAgentIdentity(agentIdentity),
|
|
880
|
-
},
|
|
881
|
-
};
|
|
882
|
-
}
|
|
883
|
-
catch (error) {
|
|
884
|
-
await maybeHandleRepoRoomAuthRequired(error, roomId);
|
|
885
|
-
if (!isMissingRouteError(error)) {
|
|
886
|
-
throw error;
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
if (joinedVia === "join_code") {
|
|
890
|
-
const project = await apiCall(`/projects/join/${encodeURIComponent(roomId)}`);
|
|
891
|
-
const legacyRoomId = typeof project.code === "string"
|
|
892
|
-
? project.code
|
|
893
|
-
: roomId;
|
|
894
|
-
const room = rememberRoom(toRoomState({
|
|
895
|
-
room_id: legacyRoomId,
|
|
896
|
-
project_id: typeof project.id === "string" ? project.id : null,
|
|
897
|
-
code: typeof project.code === "string" ? project.code : legacyRoomId,
|
|
898
|
-
display_name: typeof project.display_name === "string" ? project.display_name : null,
|
|
899
|
-
joined_via: joinedVia,
|
|
900
|
-
}));
|
|
901
|
-
const agentIdentity = await ensureAgentIdentity();
|
|
902
|
-
await syncRoomPresence(room.room_id, agentIdentity, {
|
|
903
|
-
status: "idle",
|
|
904
|
-
status_text: "online in room",
|
|
905
|
-
});
|
|
906
|
-
return {
|
|
907
|
-
room,
|
|
908
|
-
response: {
|
|
909
|
-
...project,
|
|
910
|
-
room_id: legacyRoomId,
|
|
911
|
-
project_id: typeof project.id === "string" ? project.id : null,
|
|
912
|
-
agent_identity: toPublicAgentIdentity(agentIdentity),
|
|
913
|
-
},
|
|
914
|
-
};
|
|
915
|
-
}
|
|
916
|
-
const project = await apiCall(`/projects/room/${encodeURIComponent(roomId)}`, { method: "POST" });
|
|
917
|
-
const legacyRoomId = typeof project.name === "string" && project.name.trim()
|
|
918
|
-
? project.name
|
|
919
|
-
: typeof project.code === "string" && project.code.trim()
|
|
920
|
-
? project.code
|
|
921
|
-
: roomId;
|
|
922
|
-
const room = rememberRoom(toRoomState({
|
|
923
|
-
room_id: legacyRoomId,
|
|
924
|
-
project_id: typeof project.id === "string" ? project.id : null,
|
|
925
|
-
code: typeof project.code === "string"
|
|
926
|
-
? project.code
|
|
927
|
-
: looksLikeInviteCode(legacyRoomId)
|
|
928
|
-
? legacyRoomId
|
|
929
|
-
: null,
|
|
930
|
-
display_name: typeof project.display_name === "string" ? project.display_name : null,
|
|
931
|
-
joined_via: joinedVia,
|
|
932
|
-
}));
|
|
933
|
-
const agentIdentity = await ensureAgentIdentity();
|
|
934
|
-
await syncRoomPresence(room.room_id, agentIdentity, {
|
|
935
|
-
status: "idle",
|
|
936
|
-
status_text: "online in room",
|
|
937
|
-
});
|
|
938
|
-
return {
|
|
939
|
-
room,
|
|
940
|
-
response: {
|
|
941
|
-
...project,
|
|
942
|
-
room_id: legacyRoomId,
|
|
943
|
-
project_id: typeof project.id === "string" ? project.id : null,
|
|
944
|
-
agent_identity: toPublicAgentIdentity(agentIdentity),
|
|
945
|
-
},
|
|
946
|
-
};
|
|
947
|
-
}
|
|
948
|
-
async function createInviteRoom() {
|
|
949
|
-
const project = await apiCall("/projects", { method: "POST" });
|
|
950
|
-
const roomId = typeof project.code === "string"
|
|
951
|
-
? project.code
|
|
952
|
-
: typeof project.id === "string"
|
|
953
|
-
? project.id
|
|
954
|
-
: "unknown-room";
|
|
955
|
-
const room = rememberRoom(toRoomState({
|
|
956
|
-
room_id: roomId,
|
|
957
|
-
project_id: typeof project.id === "string" ? project.id : null,
|
|
958
|
-
code: typeof project.code === "string" ? project.code : roomId,
|
|
959
|
-
display_name: typeof project.display_name === "string" ? project.display_name : null,
|
|
960
|
-
joined_via: "join_code",
|
|
961
|
-
}));
|
|
962
|
-
const agentIdentity = await ensureAgentIdentity();
|
|
963
|
-
await syncRoomPresence(room.room_id, agentIdentity, {
|
|
964
|
-
status: "idle",
|
|
965
|
-
status_text: "online in room",
|
|
966
|
-
});
|
|
967
|
-
return {
|
|
968
|
-
room,
|
|
969
|
-
response: {
|
|
970
|
-
...toPublicRoomResponse(project, roomId),
|
|
971
|
-
agent_identity: toPublicAgentIdentity(agentIdentity),
|
|
972
|
-
},
|
|
973
|
-
};
|
|
974
|
-
}
|
|
975
|
-
async function buildJoinResponse(input) {
|
|
976
|
-
const basePayload = await withAgentIdentity({
|
|
977
|
-
...toPublicRoomResponse(input.joined.response, input.joined.room.room_id),
|
|
978
|
-
joined_via: input.joined_via,
|
|
979
|
-
session_mode: input.session_mode,
|
|
980
|
-
});
|
|
981
|
-
if (input.session_mode === "current") {
|
|
982
|
-
return withJoinRoomAgentPrompt(basePayload);
|
|
983
|
-
}
|
|
984
|
-
const liveSession = await startLocalCodexSession({
|
|
985
|
-
room_id: input.joined.room.room_id,
|
|
986
|
-
room_identifier: input.room_identifier,
|
|
987
|
-
room_code: input.joined.room.code ?? null,
|
|
988
|
-
room_display_name: input.joined.room.display_name ?? null,
|
|
989
|
-
joined_via: input.joined_via,
|
|
990
|
-
cwd: process.cwd(),
|
|
991
|
-
});
|
|
992
|
-
return withJoinRoomAgentPrompt({
|
|
993
|
-
...basePayload,
|
|
994
|
-
local_codex_session: toPublicCodexLiveSession(liveSession.session),
|
|
995
|
-
local_codex_session_started: !liveSession.reused,
|
|
996
|
-
local_codex_session_reused: liveSession.reused,
|
|
997
|
-
});
|
|
998
|
-
}
|
|
999
|
-
async function joinInviteCode(code, sessionMode) {
|
|
1000
|
-
const joined = await joinRoomIdentifier(code, "join_code");
|
|
1001
|
-
return buildJoinResponse({
|
|
1002
|
-
joined,
|
|
1003
|
-
room_identifier: normalizeInviteCode(code),
|
|
1004
|
-
joined_via: "join_code",
|
|
1005
|
-
session_mode: sessionMode,
|
|
1006
|
-
});
|
|
1007
|
-
}
|
|
1008
|
-
async function joinNamedRoom(name, sessionMode) {
|
|
1009
|
-
const joined = await joinRoomIdentifier(name, "join_room");
|
|
1010
|
-
return buildJoinResponse({
|
|
1011
|
-
joined,
|
|
1012
|
-
room_identifier: name.trim(),
|
|
1013
|
-
joined_via: "join_room",
|
|
1014
|
-
session_mode: sessionMode,
|
|
1015
|
-
});
|
|
1016
|
-
}
|
|
1017
|
-
// ---------------------------------------------------------------------------
|
|
1018
|
-
// MCP Server
|
|
1019
|
-
// ---------------------------------------------------------------------------
|
|
4
|
+
import { registerRoomResources } from "./server/resources.js";
|
|
5
|
+
import { registerTools } from "./server/register-tools.js";
|
|
6
|
+
import { attachMcpServer, autoJoinFromContext, shutdownRuntime } from "./server/runtime.js";
|
|
1020
7
|
const server = new McpServer({
|
|
1021
8
|
name: "letagents",
|
|
1022
9
|
version: "0.2.0",
|
|
1023
10
|
});
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
// ---------------------------------------------------------------------------
|
|
1028
|
-
server.resource("room_messages", new ResourceTemplate("letagents://rooms/{room_id}/messages", {
|
|
1029
|
-
list: undefined,
|
|
1030
|
-
}), async (uri, { room_id }) => {
|
|
1031
|
-
const normalizedRoomId = String(room_id);
|
|
1032
|
-
const storedSession = getStoredRoomSession(normalizedRoomId) ??
|
|
1033
|
-
(currentRoom?.room_id === normalizedRoomId ? getStoredCurrentRoom() : null);
|
|
1034
|
-
// Paginate through all pages to return full message history
|
|
1035
|
-
const allMessages = [];
|
|
1036
|
-
let afterCursor;
|
|
1037
|
-
for (;;) {
|
|
1038
|
-
const query = new URLSearchParams();
|
|
1039
|
-
if (afterCursor)
|
|
1040
|
-
query.set("after", afterCursor);
|
|
1041
|
-
const qs = query.toString();
|
|
1042
|
-
const result = await roomScopedApiCall({
|
|
1043
|
-
room_id: normalizedRoomId,
|
|
1044
|
-
project_id: storedSession?.project_id ?? null,
|
|
1045
|
-
room_path: (targetRoomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(targetRoomId)}/messages${qs ? `?${qs}` : ""}`),
|
|
1046
|
-
project_path: (projectId) => appendIncludePromptOnly(`/projects/${encodeURIComponent(projectId)}/messages${qs ? `?${qs}` : ""}`),
|
|
1047
|
-
});
|
|
1048
|
-
const msgs = result.messages ?? [];
|
|
1049
|
-
allMessages.push(...msgs);
|
|
1050
|
-
if (!result.has_more || msgs.length === 0)
|
|
1051
|
-
break;
|
|
1052
|
-
const lastMsg = msgs[msgs.length - 1];
|
|
1053
|
-
if (!lastMsg?.id)
|
|
1054
|
-
break;
|
|
1055
|
-
afterCursor = lastMsg.id;
|
|
1056
|
-
}
|
|
1057
|
-
return {
|
|
1058
|
-
contents: [
|
|
1059
|
-
{
|
|
1060
|
-
uri: uri.href,
|
|
1061
|
-
mimeType: "application/json",
|
|
1062
|
-
text: JSON.stringify({ messages: toAgentReadableMessages(allMessages) }, null, 2),
|
|
1063
|
-
},
|
|
1064
|
-
],
|
|
1065
|
-
};
|
|
1066
|
-
});
|
|
1067
|
-
// -- create_room ------------------------------------------------------------
|
|
1068
|
-
server.tool("create_room", "Create a new invite room on Let Agents Chat. Returns the room ID and join code.", {}, async () => {
|
|
1069
|
-
const created = await createInviteRoom();
|
|
1070
|
-
return {
|
|
1071
|
-
content: [
|
|
1072
|
-
{
|
|
1073
|
-
type: "text",
|
|
1074
|
-
text: JSON.stringify(withJoinRoomAgentPrompt(created.response), null, 2),
|
|
1075
|
-
},
|
|
1076
|
-
],
|
|
1077
|
-
};
|
|
1078
|
-
});
|
|
1079
|
-
// -- create_project ---------------------------------------------------------
|
|
1080
|
-
server.tool("create_project", "Legacy alias for create_room. Creates a new invite room and returns its join code.", {}, async () => {
|
|
1081
|
-
const created = await createInviteRoom();
|
|
1082
|
-
return {
|
|
1083
|
-
content: [
|
|
1084
|
-
{
|
|
1085
|
-
type: "text",
|
|
1086
|
-
text: JSON.stringify(withJoinRoomAgentPrompt(created.response), null, 2),
|
|
1087
|
-
},
|
|
1088
|
-
],
|
|
1089
|
-
};
|
|
1090
|
-
});
|
|
1091
|
-
// -- join_code --------------------------------------------------------------
|
|
1092
|
-
server.tool("join_code", "Join an existing room using an invite code.", {
|
|
1093
|
-
code: z.string().describe("The invite code shared for the room (e.g. 'ABCX-7291')"),
|
|
1094
|
-
session_mode: z
|
|
1095
|
-
.enum(["live", "current"])
|
|
1096
|
-
.optional()
|
|
1097
|
-
.describe("Use 'current' (default) for a normal inline join. Use 'live' to start/reuse a detached local Codex room worker."),
|
|
1098
|
-
}, async ({ code, session_mode }) => {
|
|
1099
|
-
return {
|
|
1100
|
-
content: [
|
|
1101
|
-
{
|
|
1102
|
-
type: "text",
|
|
1103
|
-
text: JSON.stringify(await joinInviteCode(code, normalizeJoinSessionMode(session_mode)), null, 2),
|
|
1104
|
-
},
|
|
1105
|
-
],
|
|
1106
|
-
};
|
|
1107
|
-
});
|
|
1108
|
-
// -- join_project -----------------------------------------------------------
|
|
1109
|
-
server.tool("join_project", "Legacy alias for join_code. Join an existing room using an invite code.", {
|
|
1110
|
-
code: z.string().describe("The invite code shared for the room (e.g. 'ABCX-7291')"),
|
|
1111
|
-
session_mode: z
|
|
1112
|
-
.enum(["live", "current"])
|
|
1113
|
-
.optional()
|
|
1114
|
-
.describe("Use 'current' (default) for a normal inline join. Use 'live' to start/reuse a detached local Codex room worker."),
|
|
1115
|
-
}, async ({ code, session_mode }) => {
|
|
1116
|
-
return {
|
|
1117
|
-
content: [
|
|
1118
|
-
{
|
|
1119
|
-
type: "text",
|
|
1120
|
-
text: JSON.stringify(await joinInviteCode(code, normalizeJoinSessionMode(session_mode)), null, 2),
|
|
1121
|
-
},
|
|
1122
|
-
],
|
|
1123
|
-
};
|
|
1124
|
-
});
|
|
1125
|
-
// -- join_room --------------------------------------------------------------
|
|
1126
|
-
server.tool("join_room", "Join a named room on Let Agents Chat. Creates the room if it doesn't exist. Use this for repo-based room joining.", {
|
|
1127
|
-
name: z.string().describe("The room name to join (e.g. 'github.com/owner/repo')"),
|
|
1128
|
-
session_mode: z
|
|
1129
|
-
.enum(["live", "current"])
|
|
1130
|
-
.optional()
|
|
1131
|
-
.describe("Use 'current' (default) for a normal inline join. Use 'live' to start/reuse a detached local Codex room worker."),
|
|
1132
|
-
}, async ({ name, session_mode }) => {
|
|
1133
|
-
try {
|
|
1134
|
-
return {
|
|
1135
|
-
content: [
|
|
1136
|
-
{
|
|
1137
|
-
type: "text",
|
|
1138
|
-
text: JSON.stringify(await joinNamedRoom(name, normalizeJoinSessionMode(session_mode)), null, 2),
|
|
1139
|
-
},
|
|
1140
|
-
],
|
|
1141
|
-
};
|
|
1142
|
-
}
|
|
1143
|
-
catch (error) {
|
|
1144
|
-
if (error instanceof RepoRoomAuthRequiredError) {
|
|
1145
|
-
return {
|
|
1146
|
-
content: [
|
|
1147
|
-
{
|
|
1148
|
-
type: "text",
|
|
1149
|
-
text: JSON.stringify(toRepoRoomAuthRequiredResult(error), null, 2),
|
|
1150
|
-
},
|
|
1151
|
-
],
|
|
1152
|
-
};
|
|
1153
|
-
}
|
|
1154
|
-
throw error;
|
|
1155
|
-
}
|
|
1156
|
-
});
|
|
1157
|
-
// -- local Codex live sessions ---------------------------------------------
|
|
1158
|
-
server.tool("start_local_codex_session", "Start or reuse a detached local Codex live session for a LetAgents room. The worker will join the room, keep polling, contribute in discussion, and do repo work from the current working directory when asked.", {
|
|
1159
|
-
room: z
|
|
1160
|
-
.string()
|
|
1161
|
-
.describe("Invite code or room name to run as a detached local Codex worker."),
|
|
1162
|
-
cwd: z
|
|
1163
|
-
.string()
|
|
1164
|
-
.optional()
|
|
1165
|
-
.describe("Working directory for repo work. Defaults to the current process directory."),
|
|
1166
|
-
stop_phrase: z
|
|
1167
|
-
.string()
|
|
1168
|
-
.optional()
|
|
1169
|
-
.describe("Exact room message text that tells the worker to stop. Defaults to /stop-codex-room."),
|
|
1170
|
-
max_minutes: z
|
|
1171
|
-
.number()
|
|
1172
|
-
.optional()
|
|
1173
|
-
.describe("Optional hard stop in minutes. Defaults to 0, which means run until stopped."),
|
|
1174
|
-
}, async ({ room, cwd, stop_phrase, max_minutes }) => {
|
|
1175
|
-
const joinedVia = looksLikeInviteCode(room) ? "join_code" : "join_room";
|
|
1176
|
-
try {
|
|
1177
|
-
const joined = await joinRoomIdentifier(room, joinedVia);
|
|
1178
|
-
const liveSession = await startLocalCodexSession({
|
|
1179
|
-
room_id: joined.room.room_id,
|
|
1180
|
-
room_identifier: joinedVia === "join_code" ? normalizeInviteCode(room) : room.trim(),
|
|
1181
|
-
room_code: joined.room.code ?? null,
|
|
1182
|
-
room_display_name: joined.room.display_name ?? null,
|
|
1183
|
-
joined_via: joinedVia,
|
|
1184
|
-
cwd: cwd || process.cwd(),
|
|
1185
|
-
stop_phrase,
|
|
1186
|
-
max_minutes,
|
|
1187
|
-
});
|
|
1188
|
-
return {
|
|
1189
|
-
content: [
|
|
1190
|
-
{
|
|
1191
|
-
type: "text",
|
|
1192
|
-
text: JSON.stringify(await withAgentIdentity({
|
|
1193
|
-
success: true,
|
|
1194
|
-
room: toPublicRoomState(joined.room),
|
|
1195
|
-
local_codex_session: toPublicCodexLiveSession(liveSession.session),
|
|
1196
|
-
local_codex_session_started: !liveSession.reused,
|
|
1197
|
-
local_codex_session_reused: liveSession.reused,
|
|
1198
|
-
}), null, 2),
|
|
1199
|
-
},
|
|
1200
|
-
],
|
|
1201
|
-
};
|
|
1202
|
-
}
|
|
1203
|
-
catch (error) {
|
|
1204
|
-
if (error instanceof RepoRoomAuthRequiredError) {
|
|
1205
|
-
return {
|
|
1206
|
-
content: [
|
|
1207
|
-
{
|
|
1208
|
-
type: "text",
|
|
1209
|
-
text: JSON.stringify(toRepoRoomAuthRequiredResult(error), null, 2),
|
|
1210
|
-
},
|
|
1211
|
-
],
|
|
1212
|
-
};
|
|
1213
|
-
}
|
|
1214
|
-
throw error;
|
|
1215
|
-
}
|
|
1216
|
-
});
|
|
1217
|
-
server.tool("status_local_codex_session", "Inspect the current detached local Codex live session, or a specific one by session_id.", {
|
|
1218
|
-
session_id: z
|
|
1219
|
-
.string()
|
|
1220
|
-
.optional()
|
|
1221
|
-
.describe("Optional session id. Defaults to the current local Codex live session."),
|
|
1222
|
-
}, async ({ session_id }) => {
|
|
1223
|
-
const status = await inspectLocalCodexSession(session_id, currentRoom?.room_id);
|
|
1224
|
-
if (!status) {
|
|
1225
|
-
return {
|
|
1226
|
-
content: [
|
|
1227
|
-
{
|
|
1228
|
-
type: "text",
|
|
1229
|
-
text: JSON.stringify({ success: false, error: "No local Codex live session found." }, null, 2),
|
|
1230
|
-
},
|
|
1231
|
-
],
|
|
1232
|
-
};
|
|
1233
|
-
}
|
|
1234
|
-
return {
|
|
1235
|
-
content: [
|
|
1236
|
-
{
|
|
1237
|
-
type: "text",
|
|
1238
|
-
text: JSON.stringify({
|
|
1239
|
-
success: true,
|
|
1240
|
-
session: toPublicCodexLiveSession(status.session),
|
|
1241
|
-
server_reachable: status.server_reachable,
|
|
1242
|
-
thread_status: status.thread_status,
|
|
1243
|
-
turn_status: status.turn_status,
|
|
1244
|
-
recent_items: status.recent_items,
|
|
1245
|
-
}, null, 2),
|
|
1246
|
-
},
|
|
1247
|
-
],
|
|
1248
|
-
};
|
|
1249
|
-
});
|
|
1250
|
-
server.tool("stop_local_codex_session", "Stop the current detached local Codex live session, or a specific one by session_id.", {
|
|
1251
|
-
session_id: z
|
|
1252
|
-
.string()
|
|
1253
|
-
.optional()
|
|
1254
|
-
.describe("Optional session id. Defaults to the current local Codex live session."),
|
|
1255
|
-
shutdown_server: z
|
|
1256
|
-
.boolean()
|
|
1257
|
-
.optional()
|
|
1258
|
-
.describe("If true, also terminate the spawned codex app-server process when possible."),
|
|
1259
|
-
}, async ({ session_id, shutdown_server }) => {
|
|
1260
|
-
const stopped = await stopLocalCodexSession({
|
|
1261
|
-
session_id,
|
|
1262
|
-
room_id: currentRoom?.room_id,
|
|
1263
|
-
shutdown_server,
|
|
1264
|
-
});
|
|
1265
|
-
if (!stopped) {
|
|
1266
|
-
return {
|
|
1267
|
-
content: [
|
|
1268
|
-
{
|
|
1269
|
-
type: "text",
|
|
1270
|
-
text: JSON.stringify({ success: false, error: "No local Codex live session found." }, null, 2),
|
|
1271
|
-
},
|
|
1272
|
-
],
|
|
1273
|
-
};
|
|
1274
|
-
}
|
|
1275
|
-
return {
|
|
1276
|
-
content: [
|
|
1277
|
-
{
|
|
1278
|
-
type: "text",
|
|
1279
|
-
text: JSON.stringify({
|
|
1280
|
-
success: true,
|
|
1281
|
-
session: toPublicCodexLiveSession(stopped),
|
|
1282
|
-
}, null, 2),
|
|
1283
|
-
},
|
|
1284
|
-
],
|
|
1285
|
-
};
|
|
1286
|
-
});
|
|
1287
|
-
// -- get_current_room -------------------------------------------------------
|
|
1288
|
-
server.tool("get_current_room", "Get information about the currently joined room, including how it was joined.", {
|
|
1289
|
-
conversation_id: z
|
|
1290
|
-
.string()
|
|
1291
|
-
.optional()
|
|
1292
|
-
.describe("Optional conversation ID to report the conversation-scoped identity instead of the global one."),
|
|
1293
|
-
}, async ({ conversation_id }) => {
|
|
1294
|
-
return {
|
|
1295
|
-
content: [
|
|
1296
|
-
{
|
|
1297
|
-
type: "text",
|
|
1298
|
-
text: JSON.stringify(currentRoom
|
|
1299
|
-
? withJoinRoomAgentPrompt({
|
|
1300
|
-
connected: true,
|
|
1301
|
-
...toPublicRoomState(currentRoom),
|
|
1302
|
-
current_local_codex_session: getCurrentLiveSessionPayload(currentRoom.room_id),
|
|
1303
|
-
local_codex_session_count: listStoredCodexLiveSessions().length,
|
|
1304
|
-
agent_identity: toPublicAgentIdentity(getConversationIdentity(conversation_id)
|
|
1305
|
-
?? currentAgentIdentity
|
|
1306
|
-
?? getStoredAgentIdentity(currentAgentIdentityKey)),
|
|
1307
|
-
auth: getStoredAuth()
|
|
1308
|
-
? {
|
|
1309
|
-
source: process.env.LETAGENTS_TOKEN ? "env" : "local_state",
|
|
1310
|
-
expires_at: getStoredAuth()?.expires_at ?? null,
|
|
1311
|
-
account: getStoredAuth()?.account ?? null,
|
|
1312
|
-
}
|
|
1313
|
-
: null,
|
|
1314
|
-
})
|
|
1315
|
-
: {
|
|
1316
|
-
connected: false,
|
|
1317
|
-
message: "Not currently in any room",
|
|
1318
|
-
current_local_codex_session: getCurrentLiveSessionPayload(), // no room context
|
|
1319
|
-
local_codex_session_count: listStoredCodexLiveSessions().length,
|
|
1320
|
-
}, null, 2),
|
|
1321
|
-
},
|
|
1322
|
-
],
|
|
1323
|
-
};
|
|
1324
|
-
});
|
|
1325
|
-
// -- check_repo -------------------------------------------------------------
|
|
1326
|
-
server.tool("check_repo", "Inspect the current repository context for Let Agents Chat. " +
|
|
1327
|
-
"Shows the git repo root, detected .letagents.json path, auto-derived room name from git remote, " +
|
|
1328
|
-
"and current room state. Useful for troubleshooting auto-join issues.", {
|
|
1329
|
-
cwd: z
|
|
1330
|
-
.string()
|
|
1331
|
-
.optional()
|
|
1332
|
-
.describe("Directory to inspect. Defaults to the current process directory."),
|
|
1333
|
-
}, async ({ cwd: targetDir }) => {
|
|
1334
|
-
const startDir = targetDir || process.cwd();
|
|
1335
|
-
const repoRoot = resolveGitRoot(startDir);
|
|
1336
|
-
const configDir = repoRoot ? findExistingConfig(startDir) : null;
|
|
1337
|
-
const configPath = configDir ? join(configDir, ".letagents.json") : null;
|
|
1338
|
-
let configContents = null;
|
|
1339
|
-
if (configPath && existsSync(configPath)) {
|
|
1340
|
-
try {
|
|
1341
|
-
const { readFileSync } = await import("fs");
|
|
1342
|
-
configContents = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1343
|
-
}
|
|
1344
|
-
catch {
|
|
1345
|
-
configContents = "<parse error>";
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
const derivedRoom = repoRoot ? getGitRemoteIdentity(repoRoot) : null;
|
|
1349
|
-
return {
|
|
1350
|
-
content: [
|
|
1351
|
-
{
|
|
1352
|
-
type: "text",
|
|
1353
|
-
text: JSON.stringify({
|
|
1354
|
-
cwd: startDir,
|
|
1355
|
-
git_repo_root: repoRoot ?? null,
|
|
1356
|
-
config_file: configPath ?? null,
|
|
1357
|
-
config_contents: configContents,
|
|
1358
|
-
derived_room_from_git: derivedRoom ?? null,
|
|
1359
|
-
current_room: toPublicRoomState(currentRoom),
|
|
1360
|
-
join_hint: !currentRoom
|
|
1361
|
-
? repoRoot
|
|
1362
|
-
? "Run initialize_repo to set up .letagents.json, or use join_room/join_code to connect."
|
|
1363
|
-
: "Not inside a git repo. Use create_room, join_code, or join_room to connect manually."
|
|
1364
|
-
: null,
|
|
1365
|
-
}, null, 2),
|
|
1366
|
-
},
|
|
1367
|
-
],
|
|
1368
|
-
};
|
|
1369
|
-
});
|
|
1370
|
-
// -- post_status ------------------------------------------------------------
|
|
1371
|
-
server.tool("post_status", "Broadcast a lightweight status update to the current room. " +
|
|
1372
|
-
"Use this to let other agents and humans know what you are currently doing, " +
|
|
1373
|
-
"e.g. 'reviewing PR #2', 'waiting for tests', 'writing WISHLIST.md'. " +
|
|
1374
|
-
"Status updates are distinct from chat messages and can be filtered separately.", {
|
|
1375
|
-
sender: z
|
|
1376
|
-
.string()
|
|
1377
|
-
.optional()
|
|
1378
|
-
.describe("Deprecated override. Agent identity is resolved automatically on room entry."),
|
|
1379
|
-
status: z.string().describe("Short status description (e.g. 'reviewing PR #2', 'idle', 'thinking...')"),
|
|
1380
|
-
room_id: z
|
|
1381
|
-
.string()
|
|
1382
|
-
.optional()
|
|
1383
|
-
.describe("Canonical room ID. Defaults to the current room."),
|
|
1384
|
-
conversation_id: z
|
|
1385
|
-
.string()
|
|
1386
|
-
.optional()
|
|
1387
|
-
.describe("Optional conversation ID for per-conversation identity scoping."),
|
|
1388
|
-
}, async ({ sender: _sender, status, room_id, conversation_id }) => {
|
|
1389
|
-
const targetRoomId = getTargetRoomId(room_id);
|
|
1390
|
-
const targetProjectId = getFallbackProjectId();
|
|
1391
|
-
if (!targetRoomId && !targetProjectId) {
|
|
1392
|
-
return {
|
|
1393
|
-
content: [
|
|
1394
|
-
{
|
|
1395
|
-
type: "text",
|
|
1396
|
-
text: JSON.stringify({
|
|
1397
|
-
success: false,
|
|
1398
|
-
error: "No room_id provided and not currently in a room.",
|
|
1399
|
-
hint: "Join or create a room first, or pass room_id explicitly.",
|
|
1400
|
-
}, null, 2),
|
|
1401
|
-
},
|
|
1402
|
-
],
|
|
1403
|
-
};
|
|
1404
|
-
}
|
|
1405
|
-
// Status messages use a reserved prefix so the UI (and agents) can distinguish
|
|
1406
|
-
// them from normal chat messages without changing the data model.
|
|
1407
|
-
const identity = getConversationIdentity(conversation_id) ?? await ensureAgentIdentity();
|
|
1408
|
-
const sender = identity.actor_label;
|
|
1409
|
-
const statusText = `[status] ${status}`;
|
|
1410
|
-
const message = await roomScopedApiCall({
|
|
1411
|
-
room_id: targetRoomId,
|
|
1412
|
-
project_id: targetProjectId,
|
|
1413
|
-
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
|
|
1414
|
-
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
|
|
1415
|
-
options: {
|
|
1416
|
-
method: "POST",
|
|
1417
|
-
body: JSON.stringify({ sender, text: statusText }),
|
|
1418
|
-
},
|
|
1419
|
-
});
|
|
1420
|
-
touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
|
|
1421
|
-
await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, {
|
|
1422
|
-
status: classifyPresenceStatusText(status, getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity).status),
|
|
1423
|
-
status_text: status,
|
|
1424
|
-
});
|
|
1425
|
-
return {
|
|
1426
|
-
content: [
|
|
1427
|
-
{
|
|
1428
|
-
type: "text",
|
|
1429
|
-
text: JSON.stringify({
|
|
1430
|
-
success: true,
|
|
1431
|
-
status_posted: status,
|
|
1432
|
-
sender,
|
|
1433
|
-
agent_identity: toPublicAgentIdentity(identity),
|
|
1434
|
-
message_id: typeof message.id === "string" ? message.id : null,
|
|
1435
|
-
timestamp: typeof message.timestamp === "string" ? message.timestamp : null,
|
|
1436
|
-
}, null, 2),
|
|
1437
|
-
},
|
|
1438
|
-
],
|
|
1439
|
-
};
|
|
1440
|
-
});
|
|
1441
|
-
// -- Task Board Tools -------------------------------------------------------
|
|
1442
|
-
const TASK_STATUSES = [
|
|
1443
|
-
"proposed", "accepted", "assigned", "in_progress",
|
|
1444
|
-
"blocked", "in_review", "merged", "done", "cancelled",
|
|
1445
|
-
];
|
|
1446
|
-
server.tool("add_task", "Add a new task to the room board. Tasks normally start as 'proposed' and must be " +
|
|
1447
|
-
"accepted before an agent can claim them, but tasks created by trusted agents already " +
|
|
1448
|
-
"active in the room may be auto-accepted. Use this when a human or agent identifies " +
|
|
1449
|
-
"work that needs to be done.", {
|
|
1450
|
-
title: z.string().describe("Short task title, e.g. 'Wire up Jest test runner'"),
|
|
1451
|
-
description: z.string().optional().describe("Longer description of what needs to be done"),
|
|
1452
|
-
created_by: z
|
|
1453
|
-
.string()
|
|
1454
|
-
.optional()
|
|
1455
|
-
.describe("Deprecated override. Agent identity is resolved automatically on room entry."),
|
|
1456
|
-
source_message_id: z.string().optional().describe("Optional message ID where task was agreed, e.g. 'msg_42'"),
|
|
1457
|
-
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
1458
|
-
conversation_id: z.string().optional().describe("Optional conversation ID for per-conversation identity scoping."),
|
|
1459
|
-
}, async ({ title, description, created_by: _createdBy, source_message_id, room_id, conversation_id }) => {
|
|
1460
|
-
const targetRoomId = getTargetRoomId(room_id);
|
|
1461
|
-
const targetProjectId = getFallbackProjectId();
|
|
1462
|
-
if (!targetRoomId && !targetProjectId) {
|
|
1463
|
-
return {
|
|
1464
|
-
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
|
|
1465
|
-
};
|
|
1466
|
-
}
|
|
1467
|
-
const identity = getConversationIdentity(conversation_id) ?? await ensureAgentIdentity();
|
|
1468
|
-
const task = await roomScopedApiCall({
|
|
1469
|
-
room_id: targetRoomId,
|
|
1470
|
-
project_id: targetProjectId,
|
|
1471
|
-
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks`,
|
|
1472
|
-
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks`,
|
|
1473
|
-
options: {
|
|
1474
|
-
method: "POST",
|
|
1475
|
-
body: JSON.stringify({
|
|
1476
|
-
title,
|
|
1477
|
-
description,
|
|
1478
|
-
created_by: identity.actor_label,
|
|
1479
|
-
source_message_id,
|
|
1480
|
-
}),
|
|
1481
|
-
},
|
|
1482
|
-
});
|
|
1483
|
-
await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
|
|
1484
|
-
return {
|
|
1485
|
-
content: [
|
|
1486
|
-
{
|
|
1487
|
-
type: "text",
|
|
1488
|
-
text: JSON.stringify({ success: true, task, agent_identity: toPublicAgentIdentity(identity) }, null, 2),
|
|
1489
|
-
},
|
|
1490
|
-
],
|
|
1491
|
-
};
|
|
1492
|
-
});
|
|
1493
|
-
server.tool("get_board", "Get the current task board for the room. By default shows only open tasks " +
|
|
1494
|
-
"(not done/cancelled). Agents should check this on startup and when idle to " +
|
|
1495
|
-
"see if there is unassigned work to claim.", {
|
|
1496
|
-
status: z.enum(TASK_STATUSES).optional().describe("Filter by specific status"),
|
|
1497
|
-
open_only: z.boolean().optional().describe("If true (default), only show tasks not done/cancelled"),
|
|
1498
|
-
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
1499
|
-
}, async ({ status, open_only, room_id }) => {
|
|
1500
|
-
const targetRoomId = getTargetRoomId(room_id);
|
|
1501
|
-
const targetProjectId = getFallbackProjectId();
|
|
1502
|
-
if (!targetRoomId && !targetProjectId) {
|
|
1503
|
-
return {
|
|
1504
|
-
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
|
|
1505
|
-
};
|
|
1506
|
-
}
|
|
1507
|
-
const params = new URLSearchParams();
|
|
1508
|
-
if (status)
|
|
1509
|
-
params.set("status", status);
|
|
1510
|
-
if (open_only !== false)
|
|
1511
|
-
params.set("open", "true");
|
|
1512
|
-
// Paginate through all pages to return the full board
|
|
1513
|
-
const allTasks = [];
|
|
1514
|
-
let afterCursor;
|
|
1515
|
-
for (;;) {
|
|
1516
|
-
const pageParams = new URLSearchParams(params);
|
|
1517
|
-
if (afterCursor)
|
|
1518
|
-
pageParams.set("after", afterCursor);
|
|
1519
|
-
const qs = pageParams.toString();
|
|
1520
|
-
const result = await roomScopedApiCall({
|
|
1521
|
-
room_id: targetRoomId,
|
|
1522
|
-
project_id: targetProjectId,
|
|
1523
|
-
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks${qs ? `?${qs}` : ""}`,
|
|
1524
|
-
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks${qs ? `?${qs}` : ""}`,
|
|
1525
|
-
});
|
|
1526
|
-
const tasks = result.tasks ?? [];
|
|
1527
|
-
allTasks.push(...tasks);
|
|
1528
|
-
if (!result.has_more || tasks.length === 0)
|
|
1529
|
-
break;
|
|
1530
|
-
const lastTask = tasks[tasks.length - 1];
|
|
1531
|
-
if (!lastTask?.id)
|
|
1532
|
-
break;
|
|
1533
|
-
afterCursor = lastTask.id;
|
|
1534
|
-
}
|
|
1535
|
-
await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, await ensureAgentIdentity());
|
|
1536
|
-
return {
|
|
1537
|
-
content: [{ type: "text", text: JSON.stringify({ success: true, tasks: allTasks }, null, 2) }],
|
|
1538
|
-
};
|
|
1539
|
-
});
|
|
1540
|
-
server.tool("get_room_events", "Get GitHub events for the room (PRs, issues, reviews, check runs, etc.). " +
|
|
1541
|
-
"Returns a paginated list of normalized GitHub events persisted from webhooks. " +
|
|
1542
|
-
"Use this to check what happened in the repo without parsing chat messages.", {
|
|
1543
|
-
event_type: z.string().optional().describe("Filter by event type: pull_request, issue, issue_comment, pull_request_review, check_run, installation, installation_repositories, repository"),
|
|
1544
|
-
object_id: z.string().optional().describe("Filter by GitHub object ID (e.g. PR number, issue number)"),
|
|
1545
|
-
actor: z.string().optional().describe("Filter by GitHub login of the actor"),
|
|
1546
|
-
since: z.string().optional().describe("ISO timestamp â only events after this time"),
|
|
1547
|
-
until: z.string().optional().describe("ISO timestamp â only events before this time"),
|
|
1548
|
-
after: z.string().optional().describe("Cursor event ID for pagination (from a previous response)"),
|
|
1549
|
-
limit: z.number().int().min(1).max(100).optional().describe("Max events to return (default 50, max 100)"),
|
|
1550
|
-
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
1551
|
-
}, async ({ event_type, object_id, actor, since, until, after, limit, room_id }) => {
|
|
1552
|
-
const targetRoomId = getTargetRoomId(room_id);
|
|
1553
|
-
const targetProjectId = getFallbackProjectId();
|
|
1554
|
-
if (!targetRoomId && !targetProjectId) {
|
|
1555
|
-
return {
|
|
1556
|
-
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
|
|
1557
|
-
};
|
|
1558
|
-
}
|
|
1559
|
-
const qs = buildRoomEventsQueryString({
|
|
1560
|
-
event_type,
|
|
1561
|
-
object_id,
|
|
1562
|
-
actor,
|
|
1563
|
-
since,
|
|
1564
|
-
until,
|
|
1565
|
-
after,
|
|
1566
|
-
limit,
|
|
1567
|
-
});
|
|
1568
|
-
const result = await roomScopedApiCall({
|
|
1569
|
-
room_id: targetRoomId,
|
|
1570
|
-
project_id: targetProjectId,
|
|
1571
|
-
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/events${qs ? `?${qs}` : ""}`,
|
|
1572
|
-
project_path: (targetProjectId) => `/rooms/${encodeURIComponent(targetProjectId)}/events${qs ? `?${qs}` : ""}`,
|
|
1573
|
-
});
|
|
1574
|
-
return {
|
|
1575
|
-
content: [{ type: "text", text: JSON.stringify({ success: true, events: result.events ?? [], has_more: result.has_more ?? false }, null, 2) }],
|
|
1576
|
-
};
|
|
1577
|
-
});
|
|
1578
|
-
server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted' " +
|
|
1579
|
-
"status. This sets the assignee to you and moves the status to 'assigned'. " +
|
|
1580
|
-
"Do NOT claim proposed tasks â they need to be accepted first.", {
|
|
1581
|
-
task_id: z.string().describe("The task ID to claim, e.g. 'task_1'"),
|
|
1582
|
-
assignee: z
|
|
1583
|
-
.string()
|
|
1584
|
-
.optional()
|
|
1585
|
-
.describe("Deprecated override. Agent identity is resolved automatically on room entry."),
|
|
1586
|
-
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
1587
|
-
conversation_id: z.string().optional().describe("Optional conversation ID for per-conversation identity scoping."),
|
|
1588
|
-
}, async ({ task_id, assignee: _assignee, room_id, conversation_id }) => {
|
|
1589
|
-
const targetRoomId = getTargetRoomId(room_id);
|
|
1590
|
-
const targetProjectId = getFallbackProjectId();
|
|
1591
|
-
if (!targetRoomId && !targetProjectId) {
|
|
1592
|
-
return {
|
|
1593
|
-
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
|
|
1594
|
-
};
|
|
1595
|
-
}
|
|
1596
|
-
try {
|
|
1597
|
-
const identity = getConversationIdentity(conversation_id) ?? await ensureAgentIdentity();
|
|
1598
|
-
const updated = await roomScopedApiCall({
|
|
1599
|
-
room_id: targetRoomId,
|
|
1600
|
-
project_id: targetProjectId,
|
|
1601
|
-
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
1602
|
-
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
1603
|
-
options: {
|
|
1604
|
-
method: "PATCH",
|
|
1605
|
-
body: JSON.stringify({
|
|
1606
|
-
status: "assigned",
|
|
1607
|
-
assignee: identity.actor_label,
|
|
1608
|
-
actor_label: identity.actor_label,
|
|
1609
|
-
actor_key: identity.canonical_key,
|
|
1610
|
-
assignee_agent_key: identity.canonical_key,
|
|
1611
|
-
}),
|
|
1612
|
-
},
|
|
1613
|
-
});
|
|
1614
|
-
await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, {
|
|
1615
|
-
status: "working",
|
|
1616
|
-
status_text: `claimed ${task_id}`,
|
|
1617
|
-
});
|
|
1618
|
-
return {
|
|
1619
|
-
content: [
|
|
1620
|
-
{
|
|
1621
|
-
type: "text",
|
|
1622
|
-
text: JSON.stringify({ success: true, task: updated, agent_identity: toPublicAgentIdentity(identity) }, null, 2),
|
|
1623
|
-
},
|
|
1624
|
-
],
|
|
1625
|
-
};
|
|
1626
|
-
}
|
|
1627
|
-
catch (error) {
|
|
1628
|
-
return {
|
|
1629
|
-
content: [{ type: "text", text: JSON.stringify({ success: false, error: String(error) }) }],
|
|
1630
|
-
};
|
|
1631
|
-
}
|
|
1632
|
-
});
|
|
1633
|
-
server.tool("update_task", "Update a task's status or assignee. Status transitions are validated â " +
|
|
1634
|
-
"only valid transitions are allowed (e.g. in_progress â in_review, " +
|
|
1635
|
-
"but NOT proposed â in_progress).", {
|
|
1636
|
-
task_id: z.string().describe("The task ID to update"),
|
|
1637
|
-
status: z.enum(TASK_STATUSES).optional().describe("New status for the task"),
|
|
1638
|
-
assignee: z
|
|
1639
|
-
.string()
|
|
1640
|
-
.optional()
|
|
1641
|
-
.describe("New assignee for the task. Defaults to the current agent when status=assigned."),
|
|
1642
|
-
pr_url: z.string().optional().describe("PR URL to link to the task"),
|
|
1643
|
-
workflow_artifacts: z
|
|
1644
|
-
.array(z.object({
|
|
1645
|
-
provider: z.enum(["github", "gitlab", "bitbucket", "unknown"]),
|
|
1646
|
-
kind: z.enum(["issue", "branch", "pull_request", "merge_request", "review", "check_run", "merge"]),
|
|
1647
|
-
id: z.string().nullable().optional(),
|
|
1648
|
-
number: z.number().int().nullable().optional(),
|
|
1649
|
-
title: z.string().nullable().optional(),
|
|
1650
|
-
url: z.string().nullable().optional(),
|
|
1651
|
-
ref: z.string().nullable().optional(),
|
|
1652
|
-
state: z.string().nullable().optional(),
|
|
1653
|
-
}).strict())
|
|
1654
|
-
.max(32)
|
|
1655
|
-
.optional()
|
|
1656
|
-
.describe("Persisted provider-neutral task workflow artifacts to attach to the task"),
|
|
1657
|
-
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
1658
|
-
conversation_id: z.string().optional().describe("Optional conversation ID for per-conversation identity scoping."),
|
|
1659
|
-
}, async ({ task_id, status, assignee, pr_url, workflow_artifacts, room_id, conversation_id }) => {
|
|
1660
|
-
const targetRoomId = getTargetRoomId(room_id);
|
|
1661
|
-
const targetProjectId = getFallbackProjectId();
|
|
1662
|
-
if (!targetRoomId && !targetProjectId) {
|
|
1663
|
-
return {
|
|
1664
|
-
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
|
|
1665
|
-
};
|
|
1666
|
-
}
|
|
1667
|
-
try {
|
|
1668
|
-
const identity = getConversationIdentity(conversation_id) ?? await ensureAgentIdentity();
|
|
1669
|
-
const nextAssignee = status === "assigned" && !assignee ? identity.actor_label : assignee;
|
|
1670
|
-
const nextAssigneeAgentKey = nextAssignee === identity.actor_label ? identity.canonical_key : undefined;
|
|
1671
|
-
const updated = await roomScopedApiCall({
|
|
1672
|
-
room_id: targetRoomId,
|
|
1673
|
-
project_id: targetProjectId,
|
|
1674
|
-
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
1675
|
-
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
1676
|
-
options: {
|
|
1677
|
-
method: "PATCH",
|
|
1678
|
-
body: JSON.stringify({
|
|
1679
|
-
status,
|
|
1680
|
-
assignee: nextAssignee,
|
|
1681
|
-
assignee_agent_key: nextAssigneeAgentKey,
|
|
1682
|
-
pr_url,
|
|
1683
|
-
workflow_artifacts,
|
|
1684
|
-
actor_label: identity.actor_label,
|
|
1685
|
-
actor_key: identity.canonical_key,
|
|
1686
|
-
}),
|
|
1687
|
-
},
|
|
1688
|
-
});
|
|
1689
|
-
await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, {
|
|
1690
|
-
status: deriveTaskPresenceStatus(status ?? null, getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity).status),
|
|
1691
|
-
status_text: status
|
|
1692
|
-
? `${task_id} -> ${status}`
|
|
1693
|
-
: getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity).status_text,
|
|
1694
|
-
});
|
|
1695
|
-
return {
|
|
1696
|
-
content: [
|
|
1697
|
-
{
|
|
1698
|
-
type: "text",
|
|
1699
|
-
text: JSON.stringify({
|
|
1700
|
-
success: true,
|
|
1701
|
-
task: updated,
|
|
1702
|
-
agent_identity: toPublicAgentIdentity(identity),
|
|
1703
|
-
}, null, 2),
|
|
1704
|
-
},
|
|
1705
|
-
],
|
|
1706
|
-
};
|
|
1707
|
-
}
|
|
1708
|
-
catch (error) {
|
|
1709
|
-
return {
|
|
1710
|
-
content: [{ type: "text", text: JSON.stringify({ success: false, error: String(error) }) }],
|
|
1711
|
-
};
|
|
1712
|
-
}
|
|
1713
|
-
});
|
|
1714
|
-
server.tool("complete_task", "Submit a task for review. Moves the task to 'in_review' status. " +
|
|
1715
|
-
"Optionally attach a PR URL. After this, a reviewer must confirm " +
|
|
1716
|
-
"the work is merged before it can be marked done.", {
|
|
1717
|
-
task_id: z.string().describe("The task ID to submit for review"),
|
|
1718
|
-
pr_url: z.string().optional().describe("GitHub PR URL for the work"),
|
|
1719
|
-
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
1720
|
-
conversation_id: z.string().optional().describe("Optional conversation ID for per-conversation identity scoping."),
|
|
1721
|
-
}, async ({ task_id, pr_url, room_id, conversation_id }) => {
|
|
1722
|
-
const targetRoomId = getTargetRoomId(room_id);
|
|
1723
|
-
const targetProjectId = getFallbackProjectId();
|
|
1724
|
-
if (!targetRoomId && !targetProjectId) {
|
|
1725
|
-
return {
|
|
1726
|
-
content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room." }) }],
|
|
1727
|
-
};
|
|
1728
|
-
}
|
|
1729
|
-
try {
|
|
1730
|
-
const identity = getConversationIdentity(conversation_id) ?? await ensureAgentIdentity();
|
|
1731
|
-
const updated = await roomScopedApiCall({
|
|
1732
|
-
room_id: targetRoomId,
|
|
1733
|
-
project_id: targetProjectId,
|
|
1734
|
-
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
1735
|
-
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
1736
|
-
options: {
|
|
1737
|
-
method: "PATCH",
|
|
1738
|
-
body: JSON.stringify({
|
|
1739
|
-
status: "in_review",
|
|
1740
|
-
pr_url,
|
|
1741
|
-
actor_label: identity.actor_label,
|
|
1742
|
-
actor_key: identity.canonical_key,
|
|
1743
|
-
}),
|
|
1744
|
-
},
|
|
1745
|
-
});
|
|
1746
|
-
await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, {
|
|
1747
|
-
status: "reviewing",
|
|
1748
|
-
status_text: `${task_id} -> in_review`,
|
|
1749
|
-
});
|
|
1750
|
-
return {
|
|
1751
|
-
content: [{
|
|
1752
|
-
type: "text",
|
|
1753
|
-
text: JSON.stringify({
|
|
1754
|
-
success: true,
|
|
1755
|
-
task: updated,
|
|
1756
|
-
agent_identity: toPublicAgentIdentity(identity),
|
|
1757
|
-
}, null, 2),
|
|
1758
|
-
}],
|
|
1759
|
-
};
|
|
1760
|
-
}
|
|
1761
|
-
catch (error) {
|
|
1762
|
-
return {
|
|
1763
|
-
content: [{ type: "text", text: JSON.stringify({ success: false, error: String(error) }) }],
|
|
1764
|
-
};
|
|
1765
|
-
}
|
|
1766
|
-
});
|
|
1767
|
-
server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat by creating a .letagents.json config file. " +
|
|
1768
|
-
"This explicitly sets up repo-based room auto-join. Reads git remote to derive the room name, " +
|
|
1769
|
-
"or accepts a custom room name. Will NOT overwrite an existing .letagents.json. " +
|
|
1770
|
-
"Always writes to the repo root, not the current working directory.", {
|
|
1771
|
-
room: z
|
|
1772
|
-
.string()
|
|
1773
|
-
.optional()
|
|
1774
|
-
.describe("Custom room name. If omitted, auto-derived from git remote (e.g. 'github.com/owner/repo')"),
|
|
1775
|
-
cwd: z
|
|
1776
|
-
.string()
|
|
1777
|
-
.optional()
|
|
1778
|
-
.describe("Working directory hint for repo detection. Defaults to the current process directory."),
|
|
1779
|
-
}, async ({ room, cwd: targetDir }) => {
|
|
1780
|
-
const startDir = targetDir || process.cwd();
|
|
1781
|
-
// Resolve true git repo root â never write to a subdirectory
|
|
1782
|
-
const repoRoot = resolveGitRoot(startDir);
|
|
1783
|
-
if (!repoRoot) {
|
|
1784
|
-
return {
|
|
1785
|
-
content: [
|
|
1786
|
-
{
|
|
1787
|
-
type: "text",
|
|
1788
|
-
text: JSON.stringify({
|
|
1789
|
-
success: false,
|
|
1790
|
-
error: "Not inside a git repository",
|
|
1791
|
-
hint: "Run this tool from inside a git repo, or pass a 'cwd' pointing to one.",
|
|
1792
|
-
}, null, 2),
|
|
1793
|
-
},
|
|
1794
|
-
],
|
|
1795
|
-
};
|
|
1796
|
-
}
|
|
1797
|
-
// Walk parent dirs from startDir (not repoRoot) to catch configs in subtrees below caller
|
|
1798
|
-
const existingConfigDir = findExistingConfig(startDir);
|
|
1799
|
-
if (existingConfigDir) {
|
|
1800
|
-
const existingPath = join(existingConfigDir, ".letagents.json");
|
|
1801
|
-
return {
|
|
1802
|
-
content: [
|
|
1803
|
-
{
|
|
1804
|
-
type: "text",
|
|
1805
|
-
text: JSON.stringify({
|
|
1806
|
-
success: false,
|
|
1807
|
-
error: ".letagents.json already exists",
|
|
1808
|
-
path: existingPath,
|
|
1809
|
-
hint: existingConfigDir === repoRoot
|
|
1810
|
-
? "Delete the existing file first if you want to reinitialize."
|
|
1811
|
-
: `Found a config in a parent directory (${existingConfigDir}). Delete it or move it to ${repoRoot} to reinitialize.`,
|
|
1812
|
-
}, null, 2),
|
|
1813
|
-
},
|
|
1814
|
-
],
|
|
1815
|
-
};
|
|
1816
|
-
}
|
|
1817
|
-
const configPath = join(repoRoot, ".letagents.json");
|
|
1818
|
-
// Safety check (shouldn't be needed after findExistingConfig, but defensive)
|
|
1819
|
-
if (existsSync(configPath)) {
|
|
1820
|
-
return {
|
|
1821
|
-
content: [
|
|
1822
|
-
{
|
|
1823
|
-
type: "text",
|
|
1824
|
-
text: JSON.stringify({
|
|
1825
|
-
success: false,
|
|
1826
|
-
error: ".letagents.json already exists",
|
|
1827
|
-
path: configPath,
|
|
1828
|
-
hint: "Delete the existing file first if you want to reinitialize.",
|
|
1829
|
-
}, null, 2),
|
|
1830
|
-
},
|
|
1831
|
-
],
|
|
1832
|
-
};
|
|
1833
|
-
}
|
|
1834
|
-
// Determine room name
|
|
1835
|
-
let roomName = room;
|
|
1836
|
-
if (!roomName) {
|
|
1837
|
-
const gitRoom = getGitRemoteIdentity(repoRoot);
|
|
1838
|
-
if (!gitRoom) {
|
|
1839
|
-
return {
|
|
1840
|
-
content: [
|
|
1841
|
-
{
|
|
1842
|
-
type: "text",
|
|
1843
|
-
text: JSON.stringify({
|
|
1844
|
-
success: false,
|
|
1845
|
-
error: "Cannot derive room name: no git remote found and no custom room name provided",
|
|
1846
|
-
hint: "Pass a 'room' parameter or run from inside a git repo with a remote configured.",
|
|
1847
|
-
}, null, 2),
|
|
1848
|
-
},
|
|
1849
|
-
],
|
|
1850
|
-
};
|
|
1851
|
-
}
|
|
1852
|
-
roomName = gitRoom;
|
|
1853
|
-
}
|
|
1854
|
-
// Write the config file
|
|
1855
|
-
const config = { room: roomName };
|
|
1856
|
-
try {
|
|
1857
|
-
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
1858
|
-
}
|
|
1859
|
-
catch (err) {
|
|
1860
|
-
return {
|
|
1861
|
-
content: [
|
|
1862
|
-
{
|
|
1863
|
-
type: "text",
|
|
1864
|
-
text: JSON.stringify({
|
|
1865
|
-
success: false,
|
|
1866
|
-
error: `Failed to write config: ${err instanceof Error ? err.message : err}`,
|
|
1867
|
-
path: configPath,
|
|
1868
|
-
}, null, 2),
|
|
1869
|
-
},
|
|
1870
|
-
],
|
|
1871
|
-
};
|
|
1872
|
-
}
|
|
1873
|
-
// Auto-join the room after creating config
|
|
1874
|
-
try {
|
|
1875
|
-
const joined = await joinRoomIdentifier(roomName, "config");
|
|
1876
|
-
return {
|
|
1877
|
-
content: [
|
|
1878
|
-
{
|
|
1879
|
-
type: "text",
|
|
1880
|
-
text: JSON.stringify({
|
|
1881
|
-
success: true,
|
|
1882
|
-
created: configPath,
|
|
1883
|
-
room_id: joined.room.room_id,
|
|
1884
|
-
code: joined.room.code ?? null,
|
|
1885
|
-
joined: true,
|
|
1886
|
-
hint: "Consider adding .letagents.json to git so other contributors auto-join the same room.",
|
|
1887
|
-
}, null, 2),
|
|
1888
|
-
},
|
|
1889
|
-
],
|
|
1890
|
-
};
|
|
1891
|
-
}
|
|
1892
|
-
catch (err) {
|
|
1893
|
-
// Config was created but auto-join failed
|
|
1894
|
-
return {
|
|
1895
|
-
content: [
|
|
1896
|
-
{
|
|
1897
|
-
type: "text",
|
|
1898
|
-
text: JSON.stringify({
|
|
1899
|
-
success: true,
|
|
1900
|
-
created: configPath,
|
|
1901
|
-
room_id: roomName,
|
|
1902
|
-
joined: false,
|
|
1903
|
-
error: `Config created but auto-join failed: ${err instanceof Error ? err.message : err}`,
|
|
1904
|
-
hint: "The .letagents.json was created. Use join_room to manually connect.",
|
|
1905
|
-
}, null, 2),
|
|
1906
|
-
},
|
|
1907
|
-
],
|
|
1908
|
-
};
|
|
1909
|
-
}
|
|
1910
|
-
});
|
|
1911
|
-
// -- send_message -----------------------------------------------------------
|
|
1912
|
-
server.tool("send_message", "Send a message to a Let Agents Chat room. Use a short nudge (e.g. continue the plan) if another participant stopped after a closing message â silence does not always mean work is finished.", {
|
|
1913
|
-
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
1914
|
-
sender: z
|
|
1915
|
-
.string()
|
|
1916
|
-
.optional()
|
|
1917
|
-
.describe("Deprecated override. Agent identity is resolved automatically on room entry."),
|
|
1918
|
-
text: z.string().describe("The message text to send"),
|
|
1919
|
-
reply_to: z
|
|
1920
|
-
.string()
|
|
1921
|
-
.optional()
|
|
1922
|
-
.describe("Optional message id to quote-reply to (for example `msg_42`)."),
|
|
1923
|
-
conversation_id: z
|
|
1924
|
-
.string()
|
|
1925
|
-
.optional()
|
|
1926
|
-
.describe("Optional conversation ID for per-conversation identity scoping."),
|
|
1927
|
-
}, async ({ room_id, sender: _sender, text, reply_to, conversation_id }) => {
|
|
1928
|
-
const targetRoomId = getTargetRoomId(room_id);
|
|
1929
|
-
const targetProjectId = getFallbackProjectId();
|
|
1930
|
-
if (!targetRoomId && !targetProjectId) {
|
|
1931
|
-
throw new Error("No room is currently selected. Join a room first or pass room_id.");
|
|
1932
|
-
}
|
|
1933
|
-
const identity = getConversationIdentity(conversation_id) ?? await ensureAgentIdentity();
|
|
1934
|
-
const message = await roomScopedApiCall({
|
|
1935
|
-
room_id: targetRoomId,
|
|
1936
|
-
project_id: targetProjectId,
|
|
1937
|
-
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages`,
|
|
1938
|
-
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
|
|
1939
|
-
options: {
|
|
1940
|
-
method: "POST",
|
|
1941
|
-
body: JSON.stringify({ sender: identity.actor_label, text, reply_to }),
|
|
1942
|
-
},
|
|
1943
|
-
});
|
|
1944
|
-
touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
|
|
1945
|
-
await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
|
|
1946
|
-
return {
|
|
1947
|
-
content: [
|
|
1948
|
-
{
|
|
1949
|
-
type: "text",
|
|
1950
|
-
text: JSON.stringify({
|
|
1951
|
-
...message,
|
|
1952
|
-
agent_identity: toPublicAgentIdentity(identity),
|
|
1953
|
-
}, null, 2),
|
|
1954
|
-
},
|
|
1955
|
-
],
|
|
1956
|
-
};
|
|
1957
|
-
});
|
|
1958
|
-
// -- read_messages ----------------------------------------------------------
|
|
1959
|
-
server.tool("read_messages", "Read all messages from a Let Agents Chat room. For long-running work, prefer wait_for_messages with after_message_id so you only process new lines and do not treat an empty poll as the end of the mission.", {
|
|
1960
|
-
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
1961
|
-
}, async ({ room_id }) => {
|
|
1962
|
-
const targetRoomId = getTargetRoomId(room_id);
|
|
1963
|
-
const targetProjectId = getFallbackProjectId();
|
|
1964
|
-
// Paginate through all pages to honor the "read all messages" contract
|
|
1965
|
-
const allMessages = [];
|
|
1966
|
-
let afterCursor;
|
|
1967
|
-
let roomIdFromResponse;
|
|
1968
|
-
for (;;) {
|
|
1969
|
-
const query = new URLSearchParams();
|
|
1970
|
-
if (afterCursor)
|
|
1971
|
-
query.set("after", afterCursor);
|
|
1972
|
-
const qs = query.toString();
|
|
1973
|
-
const result = await roomScopedApiCall({
|
|
1974
|
-
room_id: targetRoomId,
|
|
1975
|
-
project_id: targetProjectId,
|
|
1976
|
-
room_path: (targetRoomId) => appendIncludePromptOnly(`/rooms/${encodeRoomIdPath(targetRoomId)}/messages${qs ? `?${qs}` : ""}`),
|
|
1977
|
-
project_path: (targetProjectId) => appendIncludePromptOnly(`/projects/${encodeURIComponent(targetProjectId)}/messages${qs ? `?${qs}` : ""}`),
|
|
1978
|
-
});
|
|
1979
|
-
roomIdFromResponse = roomIdFromResponse || result.room_id || result.project_id;
|
|
1980
|
-
const msgs = result.messages ?? [];
|
|
1981
|
-
allMessages.push(...msgs);
|
|
1982
|
-
if (!result.has_more || msgs.length === 0)
|
|
1983
|
-
break;
|
|
1984
|
-
// Use the last message ID as the cursor for the next page
|
|
1985
|
-
const lastMsg = msgs[msgs.length - 1];
|
|
1986
|
-
if (!lastMsg?.id)
|
|
1987
|
-
break;
|
|
1988
|
-
afterCursor = lastMsg.id;
|
|
1989
|
-
}
|
|
1990
|
-
await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, await ensureAgentIdentity());
|
|
1991
|
-
const output = { messages: toAgentReadableMessages(allMessages) };
|
|
1992
|
-
if (roomIdFromResponse) {
|
|
1993
|
-
output[targetRoomId ? "room_id" : "project_id"] = roomIdFromResponse;
|
|
1994
|
-
}
|
|
1995
|
-
return {
|
|
1996
|
-
content: [
|
|
1997
|
-
{
|
|
1998
|
-
type: "text",
|
|
1999
|
-
text: JSON.stringify(output, null, 2),
|
|
2000
|
-
},
|
|
2001
|
-
],
|
|
2002
|
-
};
|
|
2003
|
-
});
|
|
2004
|
-
// -- wait_for_messages ------------------------------------------------------
|
|
2005
|
-
const DEFAULT_POLL_TIMEOUT_MS = 30000; // 30 seconds
|
|
2006
|
-
server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat room (HTTP long-poll). For multi-hour runs, call in a loop: always pass after_message_id from the last message you processed so an empty result means 'nothing new yet', not 'stop working'. If someone posted a premature 'I will wait' closing line, use send_message with a brief continue instruction. Per-call wait is capped (default max 180s unless LETAGENTS_POLL_MAX_MS is set on API and MCP).", {
|
|
2007
|
-
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
2008
|
-
after_message_id: z
|
|
2009
|
-
.string()
|
|
2010
|
-
.optional()
|
|
2011
|
-
.describe("Only return messages after this message ID (e.g. 'msg_3'). If omitted, returns all existing messages immediately."),
|
|
2012
|
-
timeout: z
|
|
2013
|
-
.number()
|
|
2014
|
-
.optional()
|
|
2015
|
-
.describe("Maximum wait time in milliseconds (min 1000, capped by LETAGENTS_POLL_MAX_MS / server). If set to 0, the default timeout will be used."),
|
|
2016
|
-
}, async ({ room_id, after_message_id, timeout }) => {
|
|
2017
|
-
const targetRoomId = getTargetRoomId(room_id);
|
|
2018
|
-
const targetProjectId = getFallbackProjectId();
|
|
2019
|
-
const identity = await ensureAgentIdentity();
|
|
2020
|
-
await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
|
|
2021
|
-
const maxPollMs = getPollTimeoutCapMs();
|
|
2022
|
-
const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), maxPollMs);
|
|
2023
|
-
const clientTimeout = serverTimeout + (serverTimeout > 120_000 ? 120_000 : 5_000);
|
|
2024
|
-
const params = new URLSearchParams();
|
|
2025
|
-
if (after_message_id)
|
|
2026
|
-
params.set("after", after_message_id);
|
|
2027
|
-
params.set("timeout", String(serverTimeout));
|
|
2028
|
-
const queryString = params.toString();
|
|
2029
|
-
const firstResult = await roomScopedApiCall({
|
|
2030
|
-
room_id: targetRoomId,
|
|
2031
|
-
project_id: targetProjectId,
|
|
2032
|
-
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages/poll?${queryString}`,
|
|
2033
|
-
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages/poll?${queryString}`,
|
|
2034
|
-
options: { signal: AbortSignal.timeout(clientTimeout) },
|
|
2035
|
-
});
|
|
2036
|
-
const allMessages = [...(firstResult.messages ?? [])];
|
|
2037
|
-
const roomIdFromResponse = firstResult.room_id || firstResult.project_id;
|
|
2038
|
-
// If the immediate response has more pages, paginate through them
|
|
2039
|
-
if (firstResult.has_more && allMessages.length > 0) {
|
|
2040
|
-
let afterCursor = allMessages[allMessages.length - 1]?.id;
|
|
2041
|
-
while (afterCursor) {
|
|
2042
|
-
const pageParams = new URLSearchParams();
|
|
2043
|
-
pageParams.set("after", afterCursor);
|
|
2044
|
-
const qs = pageParams.toString();
|
|
2045
|
-
const page = await roomScopedApiCall({
|
|
2046
|
-
room_id: targetRoomId,
|
|
2047
|
-
project_id: targetProjectId,
|
|
2048
|
-
room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/messages?${qs}`,
|
|
2049
|
-
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages?${qs}`,
|
|
2050
|
-
});
|
|
2051
|
-
const msgs = page.messages ?? [];
|
|
2052
|
-
allMessages.push(...msgs);
|
|
2053
|
-
if (!page.has_more || msgs.length === 0)
|
|
2054
|
-
break;
|
|
2055
|
-
afterCursor = msgs[msgs.length - 1]?.id;
|
|
2056
|
-
if (!afterCursor)
|
|
2057
|
-
break;
|
|
2058
|
-
}
|
|
2059
|
-
}
|
|
2060
|
-
const output = { messages: toAgentReadableMessages(allMessages) };
|
|
2061
|
-
if (roomIdFromResponse) {
|
|
2062
|
-
output[targetRoomId ? "room_id" : "project_id"] = roomIdFromResponse;
|
|
2063
|
-
}
|
|
2064
|
-
if (targetRoomId) {
|
|
2065
|
-
touchRoomSession(targetRoomId, getLastMessageId(output));
|
|
2066
|
-
}
|
|
2067
|
-
await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
|
|
2068
|
-
return {
|
|
2069
|
-
content: [
|
|
2070
|
-
{
|
|
2071
|
-
type: "text",
|
|
2072
|
-
text: JSON.stringify(output, null, 2),
|
|
2073
|
-
},
|
|
2074
|
-
],
|
|
2075
|
-
};
|
|
2076
|
-
});
|
|
2077
|
-
// -- onboarding -------------------------------------------------------------
|
|
2078
|
-
server.tool("get_onboarding_status", "Inspect local Let Agents MCP auth and room-session state so a user can finish onboarding without guessing what is missing.", {
|
|
2079
|
-
cwd: z
|
|
2080
|
-
.string()
|
|
2081
|
-
.optional()
|
|
2082
|
-
.describe("Working directory to inspect for repo context. Defaults to the current process directory."),
|
|
2083
|
-
}, async ({ cwd }) => {
|
|
2084
|
-
const workingDir = cwd || process.cwd();
|
|
2085
|
-
const repoRoot = resolveGitRoot(workingDir);
|
|
2086
|
-
const configRoom = getRoomFromConfig(workingDir);
|
|
2087
|
-
const gitRoom = repoRoot ? getGitRemoteIdentity(repoRoot) : null;
|
|
2088
|
-
const storedAuth = getStoredAuth();
|
|
2089
|
-
const pendingAuth = getPendingDeviceAuth();
|
|
2090
|
-
const savedCurrentRoom = getStoredCurrentRoom();
|
|
2091
|
-
const detectedRoom = configRoom || gitRoom;
|
|
2092
|
-
let nextStep = "join_room";
|
|
2093
|
-
if (!storedAuth && pendingAuth) {
|
|
2094
|
-
nextStep = "poll_device_auth";
|
|
2095
|
-
}
|
|
2096
|
-
else if (savedCurrentRoom && !currentRoom) {
|
|
2097
|
-
nextStep = "resume_room_session";
|
|
2098
|
-
}
|
|
2099
|
-
return {
|
|
2100
|
-
content: [
|
|
2101
|
-
{
|
|
2102
|
-
type: "text",
|
|
2103
|
-
text: JSON.stringify({
|
|
2104
|
-
api_url: API_URL,
|
|
2105
|
-
local_state_path: getLocalStatePath(),
|
|
2106
|
-
authenticated: Boolean(process.env.LETAGENTS_TOKEN || storedAuth),
|
|
2107
|
-
auth_source: process.env.LETAGENTS_TOKEN
|
|
2108
|
-
? "env"
|
|
2109
|
-
: storedAuth
|
|
2110
|
-
? "local_state"
|
|
2111
|
-
: "none",
|
|
2112
|
-
account: storedAuth?.account ?? null,
|
|
2113
|
-
token_expires_at: storedAuth?.expires_at ?? null,
|
|
2114
|
-
pending_device_auth: pendingAuth,
|
|
2115
|
-
agent_identity: toPublicAgentIdentity(currentAgentIdentity ?? getStoredAgentIdentity(currentAgentIdentityKey)),
|
|
2116
|
-
current_room: toPublicRoomState(currentRoom),
|
|
2117
|
-
saved_current_room: toPublicStoredRoomSession(savedCurrentRoom),
|
|
2118
|
-
detected_room_from_context: detectedRoom,
|
|
2119
|
-
repo_root: repoRoot,
|
|
2120
|
-
next_step: nextStep,
|
|
2121
|
-
}, null, 2),
|
|
2122
|
-
},
|
|
2123
|
-
],
|
|
2124
|
-
};
|
|
2125
|
-
});
|
|
2126
|
-
server.tool("start_device_auth", "Start GitHub Device Flow for Let Agents Chat and persist the pending request locally. Use this when private repo access or explicit LetAgents auth is needed.", {
|
|
2127
|
-
room_id: z
|
|
2128
|
-
.string()
|
|
2129
|
-
.optional()
|
|
2130
|
-
.describe("Optional room to associate with this auth request for later auto-join."),
|
|
2131
|
-
force: z
|
|
2132
|
-
.boolean()
|
|
2133
|
-
.optional()
|
|
2134
|
-
.describe("If true, replaces any existing pending device auth request."),
|
|
2135
|
-
}, async ({ room_id, force }) => {
|
|
2136
|
-
const existing = getPendingDeviceAuth();
|
|
2137
|
-
if (existing && !force) {
|
|
2138
|
-
return {
|
|
2139
|
-
content: [
|
|
2140
|
-
{
|
|
2141
|
-
type: "text",
|
|
2142
|
-
text: JSON.stringify({
|
|
2143
|
-
success: true,
|
|
2144
|
-
reused_existing_request: true,
|
|
2145
|
-
...existing,
|
|
2146
|
-
}, null, 2),
|
|
2147
|
-
},
|
|
2148
|
-
],
|
|
2149
|
-
};
|
|
2150
|
-
}
|
|
2151
|
-
const response = await apiCall("/auth/device/start", {
|
|
2152
|
-
method: "POST",
|
|
2153
|
-
});
|
|
2154
|
-
const pendingAuth = setPendingDeviceAuth({
|
|
2155
|
-
request_id: response.request_id,
|
|
2156
|
-
user_code: response.user_code,
|
|
2157
|
-
verification_uri: response.verification_uri,
|
|
2158
|
-
interval_seconds: response.interval,
|
|
2159
|
-
expires_at: new Date(Date.now() + response.expires_in * 1000).toISOString(),
|
|
2160
|
-
started_at: new Date().toISOString(),
|
|
2161
|
-
suggested_room_id: room_id ?? currentRoom?.room_id ?? getRoomFromConfig() ?? undefined,
|
|
2162
|
-
});
|
|
2163
|
-
return {
|
|
2164
|
-
content: [
|
|
2165
|
-
{
|
|
2166
|
-
type: "text",
|
|
2167
|
-
text: JSON.stringify({
|
|
2168
|
-
success: true,
|
|
2169
|
-
...pendingAuth,
|
|
2170
|
-
}, null, 2),
|
|
2171
|
-
},
|
|
2172
|
-
],
|
|
2173
|
-
};
|
|
2174
|
-
});
|
|
2175
|
-
server.tool("poll_device_auth", "Poll a pending GitHub Device Flow request. On success this stores the LetAgents token locally and can optionally join a room immediately.", {
|
|
2176
|
-
request_id: z
|
|
2177
|
-
.string()
|
|
2178
|
-
.optional()
|
|
2179
|
-
.describe("The device auth request to poll. Defaults to the locally saved pending request."),
|
|
2180
|
-
room_id: z
|
|
2181
|
-
.string()
|
|
2182
|
-
.optional()
|
|
2183
|
-
.describe("Optional room to auto-join after authorization succeeds."),
|
|
2184
|
-
auto_join: z
|
|
2185
|
-
.boolean()
|
|
2186
|
-
.optional()
|
|
2187
|
-
.describe("If true, tries to join the room immediately after the auth succeeds."),
|
|
2188
|
-
}, async ({ request_id, room_id, auto_join }) => {
|
|
2189
|
-
const pendingAuth = request_id
|
|
2190
|
-
? getPendingDeviceAuth()?.request_id === request_id
|
|
2191
|
-
? getPendingDeviceAuth()
|
|
2192
|
-
: null
|
|
2193
|
-
: getPendingDeviceAuth();
|
|
2194
|
-
if (!pendingAuth && !request_id) {
|
|
2195
|
-
return {
|
|
2196
|
-
content: [
|
|
2197
|
-
{
|
|
2198
|
-
type: "text",
|
|
2199
|
-
text: JSON.stringify({ success: false, error: "No pending device auth request found." }, null, 2),
|
|
2200
|
-
},
|
|
2201
|
-
],
|
|
2202
|
-
};
|
|
2203
|
-
}
|
|
2204
|
-
const requestId = request_id || pendingAuth?.request_id;
|
|
2205
|
-
if (!requestId) {
|
|
2206
|
-
return {
|
|
2207
|
-
content: [
|
|
2208
|
-
{
|
|
2209
|
-
type: "text",
|
|
2210
|
-
text: JSON.stringify({ success: false, error: "A request_id is required when nothing is saved locally." }, null, 2),
|
|
2211
|
-
},
|
|
2212
|
-
],
|
|
2213
|
-
};
|
|
2214
|
-
}
|
|
2215
|
-
const result = await apiCall(`/auth/device/poll/${encodeURIComponent(requestId)}`);
|
|
2216
|
-
if (result.status === "pending" || result.status === "slow_down") {
|
|
2217
|
-
if (pendingAuth) {
|
|
2218
|
-
setPendingDeviceAuth({
|
|
2219
|
-
...pendingAuth,
|
|
2220
|
-
interval_seconds: result.interval ?? pendingAuth.interval_seconds,
|
|
2221
|
-
expires_at: result.expires_in !== undefined
|
|
2222
|
-
? new Date(Date.now() + result.expires_in * 1000).toISOString()
|
|
2223
|
-
: pendingAuth.expires_at,
|
|
2224
|
-
});
|
|
2225
|
-
}
|
|
2226
|
-
return {
|
|
2227
|
-
content: [
|
|
2228
|
-
{
|
|
2229
|
-
type: "text",
|
|
2230
|
-
text: JSON.stringify({ success: true, ...result }, null, 2),
|
|
2231
|
-
},
|
|
2232
|
-
],
|
|
2233
|
-
};
|
|
2234
|
-
}
|
|
2235
|
-
if (result.status === "denied" || result.status === "expired") {
|
|
2236
|
-
clearPendingDeviceAuth();
|
|
2237
|
-
clearStoredAuth();
|
|
2238
|
-
currentAuthenticatedAccount = undefined;
|
|
2239
|
-
currentAuthenticatedAccountSource = null;
|
|
2240
|
-
currentAuthenticatedEnvToken = null;
|
|
2241
|
-
return {
|
|
2242
|
-
content: [
|
|
2243
|
-
{
|
|
2244
|
-
type: "text",
|
|
2245
|
-
text: JSON.stringify({ success: false, ...result }, null, 2),
|
|
2246
|
-
},
|
|
2247
|
-
],
|
|
2248
|
-
};
|
|
2249
|
-
}
|
|
2250
|
-
if (!result.letagents_token) {
|
|
2251
|
-
throw new Error("Device auth completed without a LetAgents token.");
|
|
2252
|
-
}
|
|
2253
|
-
clearPendingDeviceAuth();
|
|
2254
|
-
const storedAuth = setStoredAuth({
|
|
2255
|
-
token: result.letagents_token,
|
|
2256
|
-
expires_at: result.expires_at,
|
|
2257
|
-
account: result.account,
|
|
2258
|
-
stored_at: new Date().toISOString(),
|
|
2259
|
-
source: "device_flow",
|
|
2260
|
-
});
|
|
2261
|
-
currentAuthenticatedAccount = storedAuth.account ?? undefined;
|
|
2262
|
-
currentAuthenticatedAccountSource = storedAuth.account ? "stored" : null;
|
|
2263
|
-
currentAuthenticatedEnvToken = null;
|
|
2264
|
-
let joinedRoom = null;
|
|
2265
|
-
const roomToJoin = room_id ||
|
|
2266
|
-
pendingAuth?.suggested_room_id ||
|
|
2267
|
-
currentRoom?.room_id ||
|
|
2268
|
-
getRoomFromConfig() ||
|
|
2269
|
-
undefined;
|
|
2270
|
-
if (auto_join && roomToJoin) {
|
|
2271
|
-
const joinedVia = looksLikeInviteCode(roomToJoin) ? "join_code" : "join_room";
|
|
2272
|
-
const joined = await joinRoomIdentifier(roomToJoin, joinedVia);
|
|
2273
|
-
joinedRoom = joined.room;
|
|
2274
|
-
}
|
|
2275
|
-
const agentIdentity = await ensureAgentIdentity();
|
|
2276
|
-
return {
|
|
2277
|
-
content: [
|
|
2278
|
-
{
|
|
2279
|
-
type: "text",
|
|
2280
|
-
text: JSON.stringify({
|
|
2281
|
-
success: true,
|
|
2282
|
-
status: "authorized",
|
|
2283
|
-
account: storedAuth.account ?? null,
|
|
2284
|
-
expires_at: storedAuth.expires_at ?? null,
|
|
2285
|
-
auto_joined_room: joinedRoom,
|
|
2286
|
-
...(joinedRoom?.room_id
|
|
2287
|
-
? withCanonicalRoomLink(joinedRoom.room_id, {})
|
|
2288
|
-
: {}),
|
|
2289
|
-
agent_identity: toPublicAgentIdentity(agentIdentity),
|
|
2290
|
-
hint: "You can change your agent's display name anytime using the set_agent_name tool.",
|
|
2291
|
-
}, null, 2),
|
|
2292
|
-
},
|
|
2293
|
-
],
|
|
2294
|
-
};
|
|
2295
|
-
});
|
|
2296
|
-
server.tool("clear_saved_auth", "Clear any locally saved LetAgents auth token and pending device auth request.", {}, async () => {
|
|
2297
|
-
clearPendingDeviceAuth();
|
|
2298
|
-
clearStoredAuth();
|
|
2299
|
-
currentAuthenticatedAccount = undefined;
|
|
2300
|
-
currentAuthenticatedAccountSource = null;
|
|
2301
|
-
currentAuthenticatedEnvToken = null;
|
|
2302
|
-
return {
|
|
2303
|
-
content: [
|
|
2304
|
-
{
|
|
2305
|
-
type: "text",
|
|
2306
|
-
text: JSON.stringify({
|
|
2307
|
-
success: true,
|
|
2308
|
-
env_token_still_present: Boolean(process.env.LETAGENTS_TOKEN),
|
|
2309
|
-
}, null, 2),
|
|
2310
|
-
},
|
|
2311
|
-
],
|
|
2312
|
-
};
|
|
2313
|
-
});
|
|
2314
|
-
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.", {
|
|
2315
|
-
name: z
|
|
2316
|
-
.string()
|
|
2317
|
-
.min(2)
|
|
2318
|
-
.max(64)
|
|
2319
|
-
.describe("The desired display name for this agent (2-64 characters)."),
|
|
2320
|
-
conversation_id: z
|
|
2321
|
-
.string()
|
|
2322
|
-
.optional()
|
|
2323
|
-
.describe("Optional conversation ID to scope this name change. When provided, only this conversation uses the new name; other conversations keep their own identity."),
|
|
2324
|
-
}, async ({ name: desiredName, conversation_id }) => {
|
|
2325
|
-
const trimmedName = desiredName.trim();
|
|
2326
|
-
if (trimmedName.length < 2 || trimmedName.length > 64) {
|
|
2327
|
-
return {
|
|
2328
|
-
content: [
|
|
2329
|
-
{
|
|
2330
|
-
type: "text",
|
|
2331
|
-
text: JSON.stringify({ success: false, error: "Name must be between 2 and 64 characters." }, null, 2),
|
|
2332
|
-
},
|
|
2333
|
-
],
|
|
2334
|
-
};
|
|
2335
|
-
}
|
|
2336
|
-
const authAvailable = Boolean(getLetagentsToken());
|
|
2337
|
-
if (!authAvailable) {
|
|
2338
|
-
return {
|
|
2339
|
-
content: [
|
|
2340
|
-
{
|
|
2341
|
-
type: "text",
|
|
2342
|
-
text: JSON.stringify({ success: false, error: "Authentication required. Run start_device_auth first." }, null, 2),
|
|
2343
|
-
},
|
|
2344
|
-
],
|
|
2345
|
-
};
|
|
2346
|
-
}
|
|
2347
|
-
const slugName = normalizeAgentBaseName(trimmedName);
|
|
2348
|
-
try {
|
|
2349
|
-
const owner = await resolveOwnerContext();
|
|
2350
|
-
const ideLabel = detectAgentIdeLabel();
|
|
2351
|
-
const ownerAttribution = formatOwnerAttribution(owner.label);
|
|
2352
|
-
const actorLabel = buildAgentActorLabel({
|
|
2353
|
-
display_name: trimmedName,
|
|
2354
|
-
owner_label: owner.label,
|
|
2355
|
-
ide_label: ideLabel,
|
|
2356
|
-
});
|
|
2357
|
-
// When conversation_id is provided, skip server-side /agents registration
|
|
2358
|
-
// to avoid creating stranded durable agent records from ephemeral renames.
|
|
2359
|
-
let canonicalKey = owner.login ? `${owner.login}/${slugName}` : null;
|
|
2360
|
-
if (!conversation_id) {
|
|
2361
|
-
const registered = await apiCall("/agents", {
|
|
2362
|
-
method: "POST",
|
|
2363
|
-
body: JSON.stringify({
|
|
2364
|
-
name: slugName,
|
|
2365
|
-
display_name: trimmedName,
|
|
2366
|
-
owner_label: owner.label,
|
|
2367
|
-
}),
|
|
2368
|
-
});
|
|
2369
|
-
if (typeof registered.canonical_key === "string") {
|
|
2370
|
-
canonicalKey = registered.canonical_key;
|
|
2371
|
-
}
|
|
2372
|
-
}
|
|
2373
|
-
const updatedIdentity = {
|
|
2374
|
-
name: slugName,
|
|
2375
|
-
display_name: trimmedName,
|
|
2376
|
-
owner_label: owner.label,
|
|
2377
|
-
owner_attribution: ownerAttribution,
|
|
2378
|
-
ide_label: ideLabel,
|
|
2379
|
-
actor_label: actorLabel,
|
|
2380
|
-
canonical_key: canonicalKey,
|
|
2381
|
-
runtime_key: currentAgentIdentityKey,
|
|
2382
|
-
source: conversation_id ? "local" : "api",
|
|
2383
|
-
resolved_at: new Date().toISOString(),
|
|
2384
|
-
};
|
|
2385
|
-
if (conversation_id) {
|
|
2386
|
-
// Scope to this conversation only
|
|
2387
|
-
setConversationIdentity(conversation_id, updatedIdentity);
|
|
2388
|
-
}
|
|
2389
|
-
else {
|
|
2390
|
-
// Global rename (backward compatible)
|
|
2391
|
-
currentAgentIdentity = setStoredAgentIdentity(updatedIdentity, currentAgentIdentityKey);
|
|
2392
|
-
}
|
|
2393
|
-
return {
|
|
2394
|
-
content: [
|
|
2395
|
-
{
|
|
2396
|
-
type: "text",
|
|
2397
|
-
text: JSON.stringify({
|
|
2398
|
-
success: true,
|
|
2399
|
-
message: `Agent name changed to "${trimmedName}".`,
|
|
2400
|
-
agent_identity: toPublicAgentIdentity(conversation_id
|
|
2401
|
-
? getConversationIdentity(conversation_id)
|
|
2402
|
-
: currentAgentIdentity),
|
|
2403
|
-
}, null, 2),
|
|
2404
|
-
},
|
|
2405
|
-
],
|
|
2406
|
-
};
|
|
2407
|
-
}
|
|
2408
|
-
catch (error) {
|
|
2409
|
-
return {
|
|
2410
|
-
content: [
|
|
2411
|
-
{
|
|
2412
|
-
type: "text",
|
|
2413
|
-
text: JSON.stringify({
|
|
2414
|
-
success: false,
|
|
2415
|
-
error: `Failed to set name: ${error instanceof Error ? error.message : String(error)}`,
|
|
2416
|
-
}, null, 2),
|
|
2417
|
-
},
|
|
2418
|
-
],
|
|
2419
|
-
};
|
|
2420
|
-
}
|
|
2421
|
-
});
|
|
2422
|
-
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.", {
|
|
2423
|
-
room_id: z
|
|
2424
|
-
.string()
|
|
2425
|
-
.optional()
|
|
2426
|
-
.describe("Optional saved room ID to resume. Defaults to the last current room."),
|
|
2427
|
-
}, async ({ room_id }) => {
|
|
2428
|
-
const savedRoom = (room_id ? getStoredRoomSession(room_id) : null) ??
|
|
2429
|
-
getStoredCurrentRoom();
|
|
2430
|
-
if (!savedRoom) {
|
|
2431
|
-
return {
|
|
2432
|
-
content: [
|
|
2433
|
-
{
|
|
2434
|
-
type: "text",
|
|
2435
|
-
text: JSON.stringify({ success: false, error: "No saved room session found." }, null, 2),
|
|
2436
|
-
},
|
|
2437
|
-
],
|
|
2438
|
-
};
|
|
2439
|
-
}
|
|
2440
|
-
try {
|
|
2441
|
-
const joined = await joinRoomIdentifier(savedRoom.room_id, savedRoom.joined_via);
|
|
2442
|
-
const agentIdentity = await ensureAgentIdentity();
|
|
2443
|
-
return {
|
|
2444
|
-
content: [
|
|
2445
|
-
{
|
|
2446
|
-
type: "text",
|
|
2447
|
-
text: JSON.stringify(withJoinRoomAgentPrompt({
|
|
2448
|
-
success: true,
|
|
2449
|
-
rejoined_from_local_state: true,
|
|
2450
|
-
server_session_resumed: false,
|
|
2451
|
-
last_message_id_before_restart: savedRoom.last_message_id ?? null,
|
|
2452
|
-
room: toPublicRoomState(joined.room),
|
|
2453
|
-
agent_identity: toPublicAgentIdentity(agentIdentity),
|
|
2454
|
-
}), null, 2),
|
|
2455
|
-
},
|
|
2456
|
-
],
|
|
2457
|
-
};
|
|
2458
|
-
}
|
|
2459
|
-
catch (error) {
|
|
2460
|
-
if (error instanceof RepoRoomAuthRequiredError) {
|
|
2461
|
-
return {
|
|
2462
|
-
content: [
|
|
2463
|
-
{
|
|
2464
|
-
type: "text",
|
|
2465
|
-
text: JSON.stringify(toRepoRoomAuthRequiredResult(error), null, 2),
|
|
2466
|
-
},
|
|
2467
|
-
],
|
|
2468
|
-
};
|
|
2469
|
-
}
|
|
2470
|
-
throw error;
|
|
2471
|
-
}
|
|
2472
|
-
});
|
|
2473
|
-
// -- check_repo_visibility --------------------------------------------------
|
|
2474
|
-
server.tool("check_repo_visibility", "Auto-detect the current repo's git remote and check if it's public or private. Returns the canonical key, provider, visibility, and suggested room type (discoverable for public, invite for private/unknown). Useful for deciding whether to auto-join a discoverable room or create an invite room.", {
|
|
2475
|
-
cwd: z
|
|
2476
|
-
.string()
|
|
2477
|
-
.optional()
|
|
2478
|
-
.describe("Working directory to detect git remote from. Defaults to the MCP server's working directory."),
|
|
2479
|
-
}, async ({ cwd }) => {
|
|
2480
|
-
const { autoDetectRepo } = await import("./repo-visibility.js");
|
|
2481
|
-
const result = await autoDetectRepo(cwd);
|
|
2482
|
-
if (!result) {
|
|
2483
|
-
return {
|
|
2484
|
-
content: [
|
|
2485
|
-
{
|
|
2486
|
-
type: "text",
|
|
2487
|
-
text: JSON.stringify({
|
|
2488
|
-
error: "Not in a git repository or no remote configured",
|
|
2489
|
-
suggestion: "Use create_room to create an invite room instead",
|
|
2490
|
-
}, null, 2),
|
|
2491
|
-
},
|
|
2492
|
-
],
|
|
2493
|
-
};
|
|
2494
|
-
}
|
|
2495
|
-
return {
|
|
2496
|
-
content: [
|
|
2497
|
-
{
|
|
2498
|
-
type: "text",
|
|
2499
|
-
text: JSON.stringify(result, null, 2),
|
|
2500
|
-
},
|
|
2501
|
-
],
|
|
2502
|
-
};
|
|
2503
|
-
});
|
|
2504
|
-
// ---------------------------------------------------------------------------
|
|
2505
|
-
// Start
|
|
2506
|
-
// ---------------------------------------------------------------------------
|
|
11
|
+
attachMcpServer(server);
|
|
12
|
+
registerRoomResources(server);
|
|
13
|
+
registerTools(server);
|
|
2507
14
|
async function main() {
|
|
2508
15
|
const transport = new StdioServerTransport();
|
|
2509
16
|
await server.connect(transport);
|
|
2510
17
|
console.error("đ Let Agents Chat MCP server running on stdio (v0.6.0)");
|
|
2511
|
-
|
|
2512
|
-
try {
|
|
2513
|
-
// 1. Try .letagents.json config
|
|
2514
|
-
const configRoom = getRoomFromConfig();
|
|
2515
|
-
if (configRoom) {
|
|
2516
|
-
await joinRoomIdentifier(configRoom, "config");
|
|
2517
|
-
await ensureAgentIdentity();
|
|
2518
|
-
console.error(`đ Auto-joined room '${configRoom}' (from .letagents.json)`);
|
|
2519
|
-
return;
|
|
2520
|
-
}
|
|
2521
|
-
// 2. Try git remote URL
|
|
2522
|
-
const gitRoom = getGitRemoteIdentity();
|
|
2523
|
-
if (gitRoom) {
|
|
2524
|
-
await joinRoomIdentifier(gitRoom, "git-remote");
|
|
2525
|
-
await ensureAgentIdentity();
|
|
2526
|
-
console.error(`đ Auto-joined room '${gitRoom}' (inferred from git remote â consider adding a .letagents.json)`);
|
|
2527
|
-
return;
|
|
2528
|
-
}
|
|
2529
|
-
// 3. Fall back to the most recent saved room session
|
|
2530
|
-
const savedCurrentRoom = getStoredCurrentRoom();
|
|
2531
|
-
if (savedCurrentRoom) {
|
|
2532
|
-
await joinRoomIdentifier(savedCurrentRoom.room_id, savedCurrentRoom.joined_via);
|
|
2533
|
-
await ensureAgentIdentity();
|
|
2534
|
-
console.error(`đ Rejoined saved room '${savedCurrentRoom.room_id}' (from local state)`);
|
|
2535
|
-
return;
|
|
2536
|
-
}
|
|
2537
|
-
// 4. No context found
|
|
2538
|
-
console.error("âšī¸ No .letagents.json, git remote, or saved room found â use create_room, join_code, or join_room to connect.");
|
|
2539
|
-
}
|
|
2540
|
-
catch (err) {
|
|
2541
|
-
if (err instanceof RepoRoomAuthRequiredError) {
|
|
2542
|
-
console.error(`đ Repo room auth required for '${err.roomId}'. Open ${err.pendingAuth.verification_uri} and enter code ${err.pendingAuth.user_code}, then run poll_device_auth.`);
|
|
2543
|
-
return;
|
|
2544
|
-
}
|
|
2545
|
-
// Auto-join failure should never block the MCP server
|
|
2546
|
-
console.error("â ī¸ Auto-join failed (server still running):", err instanceof Error ? err.message : err);
|
|
2547
|
-
}
|
|
18
|
+
await autoJoinFromContext();
|
|
2548
19
|
}
|
|
2549
|
-
// Cleanup on exit
|
|
2550
20
|
process.on("SIGINT", () => {
|
|
2551
|
-
|
|
21
|
+
shutdownRuntime();
|
|
2552
22
|
process.exit(0);
|
|
2553
23
|
});
|
|
2554
24
|
process.on("SIGTERM", () => {
|
|
2555
|
-
|
|
25
|
+
shutdownRuntime();
|
|
2556
26
|
process.exit(0);
|
|
2557
27
|
});
|
|
2558
28
|
main().catch((err) => {
|