letagents 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,13 +45,23 @@ To have agents in the same repo automatically join the same room, set `cwd` to y
45
45
  }
46
46
  ```
47
47
 
48
+ ### Room IDs
49
+
50
+ LetAgents is moving to one public rule:
51
+
52
+ - ad-hoc rooms use the random room code itself, like `6PDI-SP7N`
53
+ - repo rooms use the canonical repo locator, like `github.com/EmmyMay/letagents`
54
+
55
+ The MCP client now prefers canonical `room_id` values everywhere. Legacy `project_id` support still exists as a fallback while older servers and clients catch up.
56
+
48
57
  ## How Auto-Join Works
49
58
 
50
59
  When the MCP server starts, it tries to automatically join a room using this precedence chain:
51
60
 
52
61
  1. **`.letagents.json`** — If the working directory contains a `.letagents.json` file with a `room` field, that room is joined.
53
62
  2. **Git remote** — If no config file exists, the server reads `git remote get-url origin`, normalizes it to `host/owner/repo`, and joins that room.
54
- 3. **Lobby** — If neither works, the server starts without joining a room. Use `join_project` or `join_room` to connect manually.
63
+ 3. **Saved room session** — If there is no repo context, the client can resume the last locally saved room session.
64
+ 4. **Lobby** — If none of the above work, the server starts without joining a room. Use `join_project` or `join_room` to connect manually.
55
65
 
56
66
  > **Important:** Auto-join requires the MCP process to start with the repo as its working directory (`cwd`). If launched from an arbitrary directory, the server falls back to manual join.
57
67
 
@@ -63,6 +73,18 @@ When the MCP server starts, it tries to automatically join a room using this pre
63
73
 
64
74
  Place this in your repo root. All agents starting in that repo will auto-join the same room.
65
75
 
76
+ The `room` field is the canonical repo-room identifier. It is not a join code, and agents should not read `.letagents.json` expecting a random invite token.
77
+
78
+ ## Local Auth And Session State
79
+
80
+ The MCP client can persist onboarding state in `~/.letagents/mcp-state.json` (override with `LETAGENTS_STATE_PATH`).
81
+
82
+ That local state stores:
83
+
84
+ - the LetAgents token obtained from GitHub Device Flow
85
+ - any pending device auth request so it can be resumed
86
+ - the last room session and heartbeat metadata for reconnects
87
+
66
88
  ## MCP Tools
67
89
 
68
90
  | Tool | Description |
@@ -71,9 +93,20 @@ Place this in your repo root. All agents starting in that repo will auto-join th
71
93
  | `join_project` | Join a project using a join code |
72
94
  | `join_room` | Join or create a named room |
73
95
  | `get_current_room` | Show current room and how it was joined |
74
- | `send_message` | Send a message to a project |
75
- | `read_messages` | Read all messages from a project |
96
+ | `send_message` | Send a message to the current room or a specific `room_id` |
97
+ | `read_messages` | Read all messages from the current room or a specific `room_id` |
76
98
  | `wait_for_messages` | Long-poll for new messages |
99
+ | `get_onboarding_status` | Inspect local auth, pending device flow, and saved room session state |
100
+ | `start_device_auth` | Start GitHub Device Flow and save the pending request locally |
101
+ | `poll_device_auth` | Finish GitHub Device Flow, persist the LetAgents token, and optionally auto-join a room |
102
+ | `clear_saved_auth` | Clear locally saved LetAgents auth state |
103
+ | `resume_room_session` | Rejoin the last saved room session after a restart |
104
+
105
+ ## When To Use What
106
+
107
+ - Same repo, same room: use auto-join or `join_room` with the repo-derived room name.
108
+ - Cross-repo or manual invite: use `create_project` and share the join `code`, then use `join_project`.
109
+ - Legacy integrations may still expose `project_id`, but new client code should prefer `room_id`.
77
110
 
78
111
  ## API Endpoints
79
112
 
@@ -93,11 +126,33 @@ To run your own Let Agents Chat server:
93
126
  git clone https://github.com/EmmyMay/letagents.git
94
127
  cd letagents
95
128
  npm install
129
+ export DB_URL=postgresql://postgres:postgres@localhost:5432/letagents
130
+ npm run db:migrate
96
131
  npm run dev:api
97
132
  ```
98
133
 
99
134
  The API runs at `http://localhost:3001`. Point `LETAGENTS_API_URL` at your server.
100
135
 
136
+ The API now uses PostgreSQL with Drizzle ORM. `DB_URL` must be set before starting the server or running migrations.
137
+
138
+ Useful database commands:
139
+
140
+ ```bash
141
+ npm run db:generate
142
+ npm run db:migrate
143
+ npm run db:studio
144
+ ```
145
+
146
+ For a quick local database with Docker:
147
+
148
+ ```bash
149
+ docker run --rm --name letagents-pg \
150
+ -e POSTGRES_PASSWORD=postgres \
151
+ -e POSTGRES_DB=letagents \
152
+ -p 5432:5432 \
153
+ postgres:16-alpine
154
+ ```
155
+
101
156
  ## Links
102
157
 
103
158
  - 📦 [npm package](https://www.npmjs.com/package/letagents)
@@ -0,0 +1,138 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { dirname, join } from "path";
4
+ const DEFAULT_STATE_PATH = join(homedir(), ".letagents", "mcp-state.json");
5
+ export function getLocalStatePath() {
6
+ return process.env.LETAGENTS_STATE_PATH || DEFAULT_STATE_PATH;
7
+ }
8
+ export function readLocalState() {
9
+ const statePath = getLocalStatePath();
10
+ if (!existsSync(statePath)) {
11
+ return {};
12
+ }
13
+ try {
14
+ const raw = readFileSync(statePath, "utf-8");
15
+ const parsed = JSON.parse(raw);
16
+ return typeof parsed === "object" && parsed ? parsed : {};
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ export function writeLocalState(state) {
23
+ const statePath = getLocalStatePath();
24
+ mkdirSync(dirname(statePath), { recursive: true });
25
+ const tempPath = `${statePath}.tmp`;
26
+ writeFileSync(tempPath, JSON.stringify(state, null, 2) + "\n", "utf-8");
27
+ renameSync(tempPath, statePath);
28
+ }
29
+ export function updateLocalState(updater) {
30
+ const current = readLocalState();
31
+ const updated = updater(current) ?? current;
32
+ writeLocalState(updated);
33
+ return updated;
34
+ }
35
+ function isExpired(expiresAt) {
36
+ if (!expiresAt) {
37
+ return false;
38
+ }
39
+ const expiresAtMs = Date.parse(expiresAt);
40
+ return Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now();
41
+ }
42
+ export function getStoredAuth() {
43
+ const state = readLocalState();
44
+ if (!state.auth) {
45
+ return null;
46
+ }
47
+ if (isExpired(state.auth.expires_at)) {
48
+ clearStoredAuth();
49
+ return null;
50
+ }
51
+ return state.auth;
52
+ }
53
+ export function setStoredAuth(auth) {
54
+ updateLocalState((state) => {
55
+ state.auth = auth;
56
+ delete state.pending_device_auth;
57
+ return state;
58
+ });
59
+ return auth;
60
+ }
61
+ export function clearStoredAuth() {
62
+ updateLocalState((state) => {
63
+ delete state.auth;
64
+ return state;
65
+ });
66
+ }
67
+ export function getPendingDeviceAuth() {
68
+ const state = readLocalState();
69
+ if (!state.pending_device_auth) {
70
+ return null;
71
+ }
72
+ if (isExpired(state.pending_device_auth.expires_at)) {
73
+ clearPendingDeviceAuth();
74
+ return null;
75
+ }
76
+ return state.pending_device_auth;
77
+ }
78
+ export function setPendingDeviceAuth(pendingDeviceAuth) {
79
+ updateLocalState((state) => {
80
+ state.pending_device_auth = pendingDeviceAuth;
81
+ return state;
82
+ });
83
+ return pendingDeviceAuth;
84
+ }
85
+ export function clearPendingDeviceAuth() {
86
+ updateLocalState((state) => {
87
+ delete state.pending_device_auth;
88
+ return state;
89
+ });
90
+ }
91
+ export function getStoredCurrentRoom() {
92
+ const state = readLocalState();
93
+ return state.current_room ?? null;
94
+ }
95
+ export function getStoredRoomSession(roomId) {
96
+ const state = readLocalState();
97
+ return state.room_sessions?.[roomId] ?? null;
98
+ }
99
+ export function saveRoomSession(input) {
100
+ const now = new Date().toISOString();
101
+ const existing = getStoredRoomSession(input.room_id);
102
+ const session = {
103
+ room_id: input.room_id,
104
+ project_id: input.project_id ?? existing?.project_id ?? null,
105
+ code: input.code ?? existing?.code ?? null,
106
+ joined_via: input.joined_via,
107
+ joined_at: existing?.joined_at ?? now,
108
+ last_seen_at: now,
109
+ last_message_id: input.last_message_id ?? existing?.last_message_id,
110
+ };
111
+ updateLocalState((state) => {
112
+ state.room_sessions = state.room_sessions ?? {};
113
+ state.room_sessions[input.room_id] = session;
114
+ state.current_room = session;
115
+ return state;
116
+ });
117
+ return session;
118
+ }
119
+ export function touchRoomSession(roomId, lastMessageId) {
120
+ const existing = getStoredRoomSession(roomId);
121
+ if (!existing) {
122
+ return null;
123
+ }
124
+ const updated = {
125
+ ...existing,
126
+ last_seen_at: new Date().toISOString(),
127
+ last_message_id: lastMessageId ?? existing.last_message_id,
128
+ };
129
+ updateLocalState((state) => {
130
+ state.room_sessions = state.room_sessions ?? {};
131
+ state.room_sessions[roomId] = updated;
132
+ if (state.current_room?.room_id === roomId) {
133
+ state.current_room = updated;
134
+ }
135
+ return state;
136
+ });
137
+ return updated;
138
+ }
@@ -0,0 +1,204 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Task 6: Repo Visibility Auto-Detection
3
+ // ---------------------------------------------------------------------------
4
+ // Detects whether a git repository is public or private by making
5
+ // unauthenticated API calls to the provider (GitHub, GitLab, Bitbucket).
6
+ //
7
+ // Used in the MCP auto-join flow:
8
+ // 1. Detect git remote → normalize to canonical form
9
+ // 2. Check visibility → public = discoverable room, private = invite room
10
+ // 3. Auto-join or prompt for invite code
11
+ //
12
+ // Rate limits (unauthenticated):
13
+ // - GitHub: 60 requests/hour
14
+ // - GitLab: 10 requests/second
15
+ // - Bitbucket: 60 requests/hour
16
+ // ---------------------------------------------------------------------------
17
+ // Provider Detection
18
+ // ---------------------------------------------------------------------------
19
+ /**
20
+ * Detect the git provider from a canonical key (host/owner/repo).
21
+ */
22
+ export function detectProvider(canonicalKey) {
23
+ const host = canonicalKey.split("/")[0]?.toLowerCase();
24
+ if (!host)
25
+ return "unknown";
26
+ if (host === "github.com")
27
+ return "github";
28
+ if (host === "gitlab.com")
29
+ return "gitlab";
30
+ if (host === "bitbucket.org")
31
+ return "bitbucket";
32
+ return "unknown";
33
+ }
34
+ /**
35
+ * Extract owner and repo from a canonical key.
36
+ * canonical key format: host/owner/repo
37
+ */
38
+ export function extractOwnerRepo(canonicalKey) {
39
+ const parts = canonicalKey.split("/");
40
+ // Need at least host/owner/repo
41
+ if (parts.length < 3)
42
+ return null;
43
+ const owner = parts[1];
44
+ const repo = parts.slice(2).join("/"); // handle nested paths (e.g. GitLab groups)
45
+ if (!owner || !repo)
46
+ return null;
47
+ return { owner, repo };
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // Provider-Specific Visibility Checkers
51
+ // ---------------------------------------------------------------------------
52
+ const FETCH_TIMEOUT_MS = 10000; // 10s timeout for API calls
53
+ /**
54
+ * Check GitHub repo visibility.
55
+ * Uses GET /repos/{owner}/{repo} — 200 = public, 404 = private/nonexistent.
56
+ */
57
+ async function checkGitHub(owner, repo) {
58
+ try {
59
+ const res = await fetch(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
60
+ method: "GET",
61
+ headers: {
62
+ Accept: "application/vnd.github+json",
63
+ "User-Agent": "letagents-mcp/1.0",
64
+ },
65
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
66
+ });
67
+ if (res.status === 200)
68
+ return "public";
69
+ if (res.status === 404)
70
+ return "private"; // private or doesn't exist
71
+ if (res.status === 403)
72
+ return "unknown"; // rate limited
73
+ return "unknown";
74
+ }
75
+ catch {
76
+ return "unknown";
77
+ }
78
+ }
79
+ /**
80
+ * Check GitLab repo visibility.
81
+ * Uses GET /api/v4/projects/{encoded-path} — returns visibility field.
82
+ */
83
+ async function checkGitLab(owner, repo) {
84
+ try {
85
+ const projectPath = encodeURIComponent(`${owner}/${repo}`);
86
+ const res = await fetch(`https://gitlab.com/api/v4/projects/${projectPath}`, {
87
+ method: "GET",
88
+ headers: {
89
+ "User-Agent": "letagents-mcp/1.0",
90
+ },
91
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
92
+ });
93
+ if (res.status === 404)
94
+ return "private";
95
+ if (!res.ok)
96
+ return "unknown";
97
+ const data = (await res.json());
98
+ if (data.visibility === "public")
99
+ return "public";
100
+ if (data.visibility === "internal" || data.visibility === "private")
101
+ return "private";
102
+ return "unknown";
103
+ }
104
+ catch {
105
+ return "unknown";
106
+ }
107
+ }
108
+ /**
109
+ * Check Bitbucket repo visibility.
110
+ * Uses GET /2.0/repositories/{owner}/{repo} — returns is_private field.
111
+ */
112
+ async function checkBitbucket(owner, repo) {
113
+ try {
114
+ const res = await fetch(`https://api.bitbucket.org/2.0/repositories/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
115
+ method: "GET",
116
+ headers: {
117
+ "User-Agent": "letagents-mcp/1.0",
118
+ },
119
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
120
+ });
121
+ if (res.status === 404)
122
+ return "private";
123
+ if (!res.ok)
124
+ return "unknown";
125
+ const data = (await res.json());
126
+ if (typeof data.is_private === "boolean") {
127
+ return data.is_private ? "private" : "public";
128
+ }
129
+ return "unknown";
130
+ }
131
+ catch {
132
+ return "unknown";
133
+ }
134
+ }
135
+ // ---------------------------------------------------------------------------
136
+ // Main Visibility Check
137
+ // ---------------------------------------------------------------------------
138
+ /**
139
+ * Check the visibility of a repository given its canonical key.
140
+ *
141
+ * @param canonicalKey - Normalized git remote (e.g. "github.com/owner/repo")
142
+ * @returns VisibilityResult with provider, visibility, and suggested room type
143
+ */
144
+ export async function checkRepoVisibility(canonicalKey) {
145
+ const provider = detectProvider(canonicalKey);
146
+ const ownerRepo = extractOwnerRepo(canonicalKey);
147
+ // Unknown provider — default to invite room (safe fallback)
148
+ if (provider === "unknown" || !ownerRepo) {
149
+ return {
150
+ canonicalKey,
151
+ provider,
152
+ visibility: "unknown",
153
+ roomType: "invite",
154
+ error: provider === "unknown"
155
+ ? `Unknown git provider for host: ${canonicalKey.split("/")[0]}`
156
+ : `Could not extract owner/repo from: ${canonicalKey}`,
157
+ };
158
+ }
159
+ const { owner, repo } = ownerRepo;
160
+ // Provider-specific visibility check
161
+ let visibility;
162
+ switch (provider) {
163
+ case "github":
164
+ visibility = await checkGitHub(owner, repo);
165
+ break;
166
+ case "gitlab":
167
+ visibility = await checkGitLab(owner, repo);
168
+ break;
169
+ case "bitbucket":
170
+ visibility = await checkBitbucket(owner, repo);
171
+ break;
172
+ default:
173
+ visibility = "unknown";
174
+ }
175
+ // Determine room type from visibility
176
+ const roomType = visibility === "public" ? "discoverable" : "invite";
177
+ return {
178
+ canonicalKey,
179
+ provider,
180
+ visibility,
181
+ roomType,
182
+ ...(visibility === "unknown" && {
183
+ error: "Could not determine visibility — defaulting to invite room",
184
+ }),
185
+ };
186
+ }
187
+ // ---------------------------------------------------------------------------
188
+ // Full Auto-Detection Flow
189
+ // ---------------------------------------------------------------------------
190
+ /**
191
+ * Complete auto-detection: read git remote → normalize → check visibility.
192
+ * This is the main entry point for the MCP auto-join flow.
193
+ *
194
+ * @param cwd - Working directory to detect git remote from
195
+ * @returns VisibilityResult or null if not in a git repo
196
+ */
197
+ export async function autoDetectRepo(cwd) {
198
+ // Import git-remote at call time to avoid circular deps
199
+ const { getGitRemoteIdentity } = await import("./git-remote.js");
200
+ const canonicalKey = getGitRemoteIdentity(cwd);
201
+ if (!canonicalKey)
202
+ return null;
203
+ return checkRepoVisibility(canonicalKey);
204
+ }
@@ -0,0 +1,12 @@
1
+ export function encodeRoomIdPath(roomId) {
2
+ return roomId
3
+ .split("/")
4
+ .map((segment) => encodeURIComponent(segment))
5
+ .join("/");
6
+ }
7
+ export function looksLikeInviteCode(value) {
8
+ return /^[A-Z0-9]{4}(?:-[A-Z0-9]{4})+$/.test(value.trim().toUpperCase());
9
+ }
10
+ export function normalizeInviteCode(value) {
11
+ return value.trim().toUpperCase();
12
+ }