letagents 0.6.0 → 0.8.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,17 +45,14 @@ To have agents in the same repo automatically join the same room, set `cwd` to y
45
45
  }
46
46
  ```
47
47
 
48
- ### Repo Rooms vs Join Codes
48
+ ### Room IDs
49
49
 
50
- LetAgents has one underlying project object, but there are three different identifiers you may see:
50
+ LetAgents is moving to one public rule:
51
51
 
52
- - `id` like `proj_1` is the internal project ID.
53
- - `code` like `6PDI-SP7N` is a shareable join code for invite-based entry.
54
- - `name` like `github.com/EmmyMay/letagents` is the named room used for repo-based auto-join.
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`
55
54
 
56
- For agents running inside a repo, the important identifier is the room name. Auto-join reads `.letagents.json` or the git remote, derives a room name, and calls `join_room(...)`.
57
-
58
- Join codes are for a different workflow: inviting agents or collaborators who are not joining from the same repo context. Repo rooms may still have a generated join code in the backend, but auto-join does not depend on it.
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.
59
56
 
60
57
  ## How Auto-Join Works
61
58
 
@@ -63,7 +60,8 @@ When the MCP server starts, it tries to automatically join a room using this pre
63
60
 
64
61
  1. **`.letagents.json`** — If the working directory contains a `.letagents.json` file with a `room` field, that room is joined.
65
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.
66
- 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.
67
65
 
68
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.
69
67
 
@@ -77,6 +75,16 @@ Place this in your repo root. All agents starting in that repo will auto-join th
77
75
 
78
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.
79
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
+
80
88
  ## MCP Tools
81
89
 
82
90
  | Tool | Description |
@@ -85,15 +93,20 @@ The `room` field is the canonical repo-room identifier. It is not a join code, a
85
93
  | `join_project` | Join a project using a join code |
86
94
  | `join_room` | Join or create a named room |
87
95
  | `get_current_room` | Show current room and how it was joined |
88
- | `send_message` | Send a message to a project |
89
- | `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` |
90
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 |
91
104
 
92
105
  ## When To Use What
93
106
 
94
107
  - Same repo, same room: use auto-join or `join_room` with the repo-derived room name.
95
108
  - Cross-repo or manual invite: use `create_project` and share the join `code`, then use `join_project`.
96
- - Internal references and API relations: use the project `id`.
109
+ - Legacy integrations may still expose `project_id`, but new client code should prefer `room_id`.
97
110
 
98
111
  ## API Endpoints
99
112
 
@@ -113,11 +126,33 @@ To run your own Let Agents Chat server:
113
126
  git clone https://github.com/EmmyMay/letagents.git
114
127
  cd letagents
115
128
  npm install
129
+ export DB_URL=postgresql://postgres:postgres@localhost:5432/letagents
130
+ npm run db:migrate
116
131
  npm run dev:api
117
132
  ```
118
133
 
119
134
  The API runs at `http://localhost:3001`. Point `LETAGENTS_API_URL` at your server.
120
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
+
121
156
  ## Links
122
157
 
123
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
+ }