letagents 0.12.2 → 0.12.4
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 +7 -1
- package/dist/mcp/agent-presence.js +43 -0
- package/dist/mcp/room-events-query.js +18 -0
- package/dist/mcp/server.js +157 -16
- package/dist/shared/agent-presence.js +26 -0
- package/dist/shared/poll-timeout-cap.js +19 -0
- package/dist/shared/room-participant.js +20 -0
- package/package.json +3 -2
- package/dist/mcp/__tests__/config-reader.test.js +0 -93
- package/dist/mcp/__tests__/git-remote.test.js +0 -37
- package/dist/mcp/__tests__/server-helpers.test.js +0 -121
package/README.md
CHANGED
|
@@ -95,7 +95,7 @@ That local state stores:
|
|
|
95
95
|
| `get_current_room` | Show current room and how it was joined |
|
|
96
96
|
| `send_message` | Send a message to the current room or a specific `room_id` |
|
|
97
97
|
| `read_messages` | Read all messages from the current room or a specific `room_id` |
|
|
98
|
-
| `wait_for_messages` | Long-poll for new messages |
|
|
98
|
+
| `wait_for_messages` | Long-poll for new messages (see **Long room watches** in `AGENTS.md` and `docs/AGENT_HANDOFF_LONG_RUNS_AND_HEADLESS.md`) |
|
|
99
99
|
| `get_onboarding_status` | Inspect local auth, pending device flow, and saved room session state |
|
|
100
100
|
| `start_device_auth` | Start GitHub Device Flow and save the pending request locally |
|
|
101
101
|
| `poll_device_auth` | Finish GitHub Device Flow, persist the LetAgents token, and optionally auto-join a room |
|
|
@@ -136,6 +136,8 @@ npm run dev:api
|
|
|
136
136
|
|
|
137
137
|
The API runs at `http://localhost:3001`. Point `LETAGENTS_API_URL` at your server.
|
|
138
138
|
|
|
139
|
+
Optional — **long room long-polls** (multi-hour `wait_for_messages` / `GET …/messages/poll`): set the **same** `LETAGENTS_POLL_MAX_MS` on **both** the API process and any MCP client you run from source (milliseconds; default `180000`). See **`docs/AGENT_HANDOFF_LONG_RUNS_AND_HEADLESS.md`**.
|
|
140
|
+
|
|
139
141
|
The API now uses PostgreSQL with Drizzle ORM. `DB_URL` must be set before starting the server or running migrations.
|
|
140
142
|
|
|
141
143
|
Useful database commands:
|
|
@@ -156,6 +158,10 @@ docker run --rm --name letagents-pg \
|
|
|
156
158
|
postgres:16-alpine
|
|
157
159
|
```
|
|
158
160
|
|
|
161
|
+
## Further documentation
|
|
162
|
+
|
|
163
|
+
- **`docs/AGENT_HANDOFF_LONG_RUNS_AND_HEADLESS.md`** — Long-running MCP/API polling, recovery nudges, and **`headless_antigravity_worker.mjs`** cascade resolve / scan / reuse (handoff for follow-up agents).
|
|
164
|
+
|
|
159
165
|
## Links
|
|
160
166
|
|
|
161
167
|
- 📦 [npm package](https://www.npmjs.com/package/letagents)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const IDLE_STATUS_RE = /\b(idle|available|online|polling|monitoring|watch(?:ing)?|ready|standby)\b/i;
|
|
2
|
+
const IDLE_WAITING_STATUS_RE = /\b(?:awaiting|waiting)\s+(?:for\s+)?(?:tasks?|work|instructions?|direction|assignment|assignments|next(?:\s+task)?|queue)\b/i;
|
|
3
|
+
const REVIEWING_STATUS_RE = /\b(review|reviewing|approve|approval|approving)\b/i;
|
|
4
|
+
const BLOCKED_STATUS_RE = /\b(blocked|waiting|stuck)\b/i;
|
|
5
|
+
export function classifyPresenceStatusText(statusText, fallback = "working") {
|
|
6
|
+
const normalized = statusText.trim();
|
|
7
|
+
if (!normalized) {
|
|
8
|
+
return fallback;
|
|
9
|
+
}
|
|
10
|
+
if (IDLE_WAITING_STATUS_RE.test(normalized)) {
|
|
11
|
+
return "idle";
|
|
12
|
+
}
|
|
13
|
+
if (BLOCKED_STATUS_RE.test(normalized)) {
|
|
14
|
+
return "blocked";
|
|
15
|
+
}
|
|
16
|
+
if (IDLE_STATUS_RE.test(normalized)) {
|
|
17
|
+
return "idle";
|
|
18
|
+
}
|
|
19
|
+
if (REVIEWING_STATUS_RE.test(normalized)) {
|
|
20
|
+
return "reviewing";
|
|
21
|
+
}
|
|
22
|
+
return "working";
|
|
23
|
+
}
|
|
24
|
+
export function deriveTaskPresenceStatus(taskStatus, fallback = "working") {
|
|
25
|
+
switch (taskStatus) {
|
|
26
|
+
case "blocked":
|
|
27
|
+
return "blocked";
|
|
28
|
+
case "in_review":
|
|
29
|
+
return "reviewing";
|
|
30
|
+
case "merged":
|
|
31
|
+
case "done":
|
|
32
|
+
case "accepted":
|
|
33
|
+
return "idle";
|
|
34
|
+
case "assigned":
|
|
35
|
+
case "in_progress":
|
|
36
|
+
return "working";
|
|
37
|
+
default:
|
|
38
|
+
return fallback;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function getRoomIdentityPresenceCacheKey(roomId, actorLabel) {
|
|
42
|
+
return JSON.stringify([roomId, actorLabel]);
|
|
43
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function buildRoomEventsQueryString(input) {
|
|
2
|
+
const params = new URLSearchParams();
|
|
3
|
+
if (input.event_type)
|
|
4
|
+
params.set("event_type", input.event_type);
|
|
5
|
+
if (input.object_id)
|
|
6
|
+
params.set("object_id", input.object_id);
|
|
7
|
+
if (input.actor)
|
|
8
|
+
params.set("actor", input.actor);
|
|
9
|
+
if (input.since)
|
|
10
|
+
params.set("since", input.since);
|
|
11
|
+
if (input.until)
|
|
12
|
+
params.set("until", input.until);
|
|
13
|
+
if (input.after)
|
|
14
|
+
params.set("after", input.after);
|
|
15
|
+
if (input.limit)
|
|
16
|
+
params.set("limit", String(input.limit));
|
|
17
|
+
return params.toString();
|
|
18
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -16,10 +16,14 @@ import { encodeRoomIdPath, getCanonicalRoomWebPath, looksLikeInviteCode, normali
|
|
|
16
16
|
import { buildAgentActorLabel, formatOwnerAttribution, inferAgentIdeLabel, toTitleCaseCodename, } from "../shared/agent-identity.js";
|
|
17
17
|
import { buildRoomAgentPrompt, normalizeAgentPromptKind, } from "../shared/room-agent-prompts.js";
|
|
18
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";
|
|
19
22
|
let currentRoom = null;
|
|
20
23
|
let currentAgentIdentityKey = "";
|
|
21
24
|
let currentAgentIdentity = null;
|
|
22
25
|
let currentAuthenticatedAccount = undefined;
|
|
26
|
+
const roomPresenceByIdentity = new Map();
|
|
23
27
|
// ---------------------------------------------------------------------------
|
|
24
28
|
// Conversation-scoped identity (Option C: per-conversation hints)
|
|
25
29
|
// ---------------------------------------------------------------------------
|
|
@@ -734,6 +738,42 @@ function touchCurrentRoom(lastMessageId) {
|
|
|
734
738
|
}
|
|
735
739
|
touchRoomSession(currentRoom.room_id, lastMessageId);
|
|
736
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
|
+
}
|
|
737
777
|
function getTargetRoomId(roomId) {
|
|
738
778
|
return roomId || currentRoom?.room_id || null;
|
|
739
779
|
}
|
|
@@ -827,6 +867,10 @@ async function joinRoomIdentifier(identifier, joinedVia) {
|
|
|
827
867
|
joined_via: joinedVia,
|
|
828
868
|
}));
|
|
829
869
|
const agentIdentity = await ensureAgentIdentity();
|
|
870
|
+
await syncRoomPresence(room.room_id, agentIdentity, {
|
|
871
|
+
status: "idle",
|
|
872
|
+
status_text: "online in room",
|
|
873
|
+
});
|
|
830
874
|
return {
|
|
831
875
|
room,
|
|
832
876
|
response: {
|
|
@@ -855,6 +899,10 @@ async function joinRoomIdentifier(identifier, joinedVia) {
|
|
|
855
899
|
joined_via: joinedVia,
|
|
856
900
|
}));
|
|
857
901
|
const agentIdentity = await ensureAgentIdentity();
|
|
902
|
+
await syncRoomPresence(room.room_id, agentIdentity, {
|
|
903
|
+
status: "idle",
|
|
904
|
+
status_text: "online in room",
|
|
905
|
+
});
|
|
858
906
|
return {
|
|
859
907
|
room,
|
|
860
908
|
response: {
|
|
@@ -883,6 +931,10 @@ async function joinRoomIdentifier(identifier, joinedVia) {
|
|
|
883
931
|
joined_via: joinedVia,
|
|
884
932
|
}));
|
|
885
933
|
const agentIdentity = await ensureAgentIdentity();
|
|
934
|
+
await syncRoomPresence(room.room_id, agentIdentity, {
|
|
935
|
+
status: "idle",
|
|
936
|
+
status_text: "online in room",
|
|
937
|
+
});
|
|
886
938
|
return {
|
|
887
939
|
room,
|
|
888
940
|
response: {
|
|
@@ -908,6 +960,10 @@ async function createInviteRoom() {
|
|
|
908
960
|
joined_via: "join_code",
|
|
909
961
|
}));
|
|
910
962
|
const agentIdentity = await ensureAgentIdentity();
|
|
963
|
+
await syncRoomPresence(room.room_id, agentIdentity, {
|
|
964
|
+
status: "idle",
|
|
965
|
+
status_text: "online in room",
|
|
966
|
+
});
|
|
911
967
|
return {
|
|
912
968
|
room,
|
|
913
969
|
response: {
|
|
@@ -1362,6 +1418,10 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
|
|
|
1362
1418
|
},
|
|
1363
1419
|
});
|
|
1364
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
|
+
});
|
|
1365
1425
|
return {
|
|
1366
1426
|
content: [
|
|
1367
1427
|
{
|
|
@@ -1420,6 +1480,7 @@ server.tool("add_task", "Add a new task to the room board. Tasks normally start
|
|
|
1420
1480
|
}),
|
|
1421
1481
|
},
|
|
1422
1482
|
});
|
|
1483
|
+
await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
|
|
1423
1484
|
return {
|
|
1424
1485
|
content: [
|
|
1425
1486
|
{
|
|
@@ -1471,10 +1532,49 @@ server.tool("get_board", "Get the current task board for the room. By default sh
|
|
|
1471
1532
|
break;
|
|
1472
1533
|
afterCursor = lastTask.id;
|
|
1473
1534
|
}
|
|
1535
|
+
await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, await ensureAgentIdentity());
|
|
1474
1536
|
return {
|
|
1475
1537
|
content: [{ type: "text", text: JSON.stringify({ success: true, tasks: allTasks }, null, 2) }],
|
|
1476
1538
|
};
|
|
1477
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
|
+
});
|
|
1478
1578
|
server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted' " +
|
|
1479
1579
|
"status. This sets the assignee to you and moves the status to 'assigned'. " +
|
|
1480
1580
|
"Do NOT claim proposed tasks — they need to be accepted first.", {
|
|
@@ -1502,9 +1602,19 @@ server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted
|
|
|
1502
1602
|
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
1503
1603
|
options: {
|
|
1504
1604
|
method: "PATCH",
|
|
1505
|
-
body: JSON.stringify({
|
|
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
|
+
}),
|
|
1506
1612
|
},
|
|
1507
1613
|
});
|
|
1614
|
+
await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, {
|
|
1615
|
+
status: "working",
|
|
1616
|
+
status_text: `claimed ${task_id}`,
|
|
1617
|
+
});
|
|
1508
1618
|
return {
|
|
1509
1619
|
content: [
|
|
1510
1620
|
{
|
|
@@ -1555,9 +1665,9 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
|
|
|
1555
1665
|
};
|
|
1556
1666
|
}
|
|
1557
1667
|
try {
|
|
1558
|
-
const identity =
|
|
1559
|
-
|
|
1560
|
-
|
|
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;
|
|
1561
1671
|
const updated = await roomScopedApiCall({
|
|
1562
1672
|
room_id: targetRoomId,
|
|
1563
1673
|
project_id: targetProjectId,
|
|
@@ -1567,12 +1677,21 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
|
|
|
1567
1677
|
method: "PATCH",
|
|
1568
1678
|
body: JSON.stringify({
|
|
1569
1679
|
status,
|
|
1570
|
-
assignee:
|
|
1680
|
+
assignee: nextAssignee,
|
|
1681
|
+
assignee_agent_key: nextAssigneeAgentKey,
|
|
1571
1682
|
pr_url,
|
|
1572
1683
|
workflow_artifacts,
|
|
1684
|
+
actor_label: identity.actor_label,
|
|
1685
|
+
actor_key: identity.canonical_key,
|
|
1573
1686
|
}),
|
|
1574
1687
|
},
|
|
1575
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
|
+
});
|
|
1576
1695
|
return {
|
|
1577
1696
|
content: [
|
|
1578
1697
|
{
|
|
@@ -1580,7 +1699,7 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
|
|
|
1580
1699
|
text: JSON.stringify({
|
|
1581
1700
|
success: true,
|
|
1582
1701
|
task: updated,
|
|
1583
|
-
agent_identity:
|
|
1702
|
+
agent_identity: toPublicAgentIdentity(identity),
|
|
1584
1703
|
}, null, 2),
|
|
1585
1704
|
},
|
|
1586
1705
|
],
|
|
@@ -1599,7 +1718,7 @@ server.tool("complete_task", "Submit a task for review. Moves the task to 'in_re
|
|
|
1599
1718
|
pr_url: z.string().optional().describe("GitHub PR URL for the work"),
|
|
1600
1719
|
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
1601
1720
|
conversation_id: z.string().optional().describe("Optional conversation ID for per-conversation identity scoping."),
|
|
1602
|
-
}, async ({ task_id, pr_url, room_id, conversation_id
|
|
1721
|
+
}, async ({ task_id, pr_url, room_id, conversation_id }) => {
|
|
1603
1722
|
const targetRoomId = getTargetRoomId(room_id);
|
|
1604
1723
|
const targetProjectId = getFallbackProjectId();
|
|
1605
1724
|
if (!targetRoomId && !targetProjectId) {
|
|
@@ -1608,6 +1727,7 @@ server.tool("complete_task", "Submit a task for review. Moves the task to 'in_re
|
|
|
1608
1727
|
};
|
|
1609
1728
|
}
|
|
1610
1729
|
try {
|
|
1730
|
+
const identity = getConversationIdentity(conversation_id) ?? await ensureAgentIdentity();
|
|
1611
1731
|
const updated = await roomScopedApiCall({
|
|
1612
1732
|
room_id: targetRoomId,
|
|
1613
1733
|
project_id: targetProjectId,
|
|
@@ -1615,11 +1735,27 @@ server.tool("complete_task", "Submit a task for review. Moves the task to 'in_re
|
|
|
1615
1735
|
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
1616
1736
|
options: {
|
|
1617
1737
|
method: "PATCH",
|
|
1618
|
-
body: JSON.stringify({
|
|
1738
|
+
body: JSON.stringify({
|
|
1739
|
+
status: "in_review",
|
|
1740
|
+
pr_url,
|
|
1741
|
+
actor_label: identity.actor_label,
|
|
1742
|
+
actor_key: identity.canonical_key,
|
|
1743
|
+
}),
|
|
1619
1744
|
},
|
|
1620
1745
|
});
|
|
1746
|
+
await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, {
|
|
1747
|
+
status: "reviewing",
|
|
1748
|
+
status_text: `${task_id} -> in_review`,
|
|
1749
|
+
});
|
|
1621
1750
|
return {
|
|
1622
|
-
content: [{
|
|
1751
|
+
content: [{
|
|
1752
|
+
type: "text",
|
|
1753
|
+
text: JSON.stringify({
|
|
1754
|
+
success: true,
|
|
1755
|
+
task: updated,
|
|
1756
|
+
agent_identity: toPublicAgentIdentity(identity),
|
|
1757
|
+
}, null, 2),
|
|
1758
|
+
}],
|
|
1623
1759
|
};
|
|
1624
1760
|
}
|
|
1625
1761
|
catch (error) {
|
|
@@ -1773,7 +1909,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
1773
1909
|
}
|
|
1774
1910
|
});
|
|
1775
1911
|
// -- send_message -----------------------------------------------------------
|
|
1776
|
-
server.tool("send_message", "Send a message to a Let Agents Chat room.", {
|
|
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.", {
|
|
1777
1913
|
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
1778
1914
|
sender: z
|
|
1779
1915
|
.string()
|
|
@@ -1806,6 +1942,7 @@ server.tool("send_message", "Send a message to a Let Agents Chat room.", {
|
|
|
1806
1942
|
},
|
|
1807
1943
|
});
|
|
1808
1944
|
touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
|
|
1945
|
+
await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
|
|
1809
1946
|
return {
|
|
1810
1947
|
content: [
|
|
1811
1948
|
{
|
|
@@ -1819,7 +1956,7 @@ server.tool("send_message", "Send a message to a Let Agents Chat room.", {
|
|
|
1819
1956
|
};
|
|
1820
1957
|
});
|
|
1821
1958
|
// -- read_messages ----------------------------------------------------------
|
|
1822
|
-
server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
|
|
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.", {
|
|
1823
1960
|
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
1824
1961
|
}, async ({ room_id }) => {
|
|
1825
1962
|
const targetRoomId = getTargetRoomId(room_id);
|
|
@@ -1850,6 +1987,7 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
|
|
|
1850
1987
|
break;
|
|
1851
1988
|
afterCursor = lastMsg.id;
|
|
1852
1989
|
}
|
|
1990
|
+
await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, await ensureAgentIdentity());
|
|
1853
1991
|
const output = { messages: toAgentReadableMessages(allMessages) };
|
|
1854
1992
|
if (roomIdFromResponse) {
|
|
1855
1993
|
output[targetRoomId ? "room_id" : "project_id"] = roomIdFromResponse;
|
|
@@ -1864,9 +2002,8 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
|
|
|
1864
2002
|
};
|
|
1865
2003
|
});
|
|
1866
2004
|
// -- wait_for_messages ------------------------------------------------------
|
|
1867
|
-
const MAX_POLL_TIMEOUT_MS = 180000; // 3 minutes
|
|
1868
2005
|
const DEFAULT_POLL_TIMEOUT_MS = 30000; // 30 seconds
|
|
1869
|
-
server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat room.
|
|
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).", {
|
|
1870
2007
|
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
1871
2008
|
after_message_id: z
|
|
1872
2009
|
.string()
|
|
@@ -1875,12 +2012,15 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat roo
|
|
|
1875
2012
|
timeout: z
|
|
1876
2013
|
.number()
|
|
1877
2014
|
.optional()
|
|
1878
|
-
.describe("Maximum wait time in milliseconds. If set to 0, the default timeout will be used."),
|
|
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."),
|
|
1879
2016
|
}, async ({ room_id, after_message_id, timeout }) => {
|
|
1880
2017
|
const targetRoomId = getTargetRoomId(room_id);
|
|
1881
2018
|
const targetProjectId = getFallbackProjectId();
|
|
1882
|
-
const
|
|
1883
|
-
|
|
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);
|
|
1884
2024
|
const params = new URLSearchParams();
|
|
1885
2025
|
if (after_message_id)
|
|
1886
2026
|
params.set("after", after_message_id);
|
|
@@ -1924,6 +2064,7 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat roo
|
|
|
1924
2064
|
if (targetRoomId) {
|
|
1925
2065
|
touchRoomSession(targetRoomId, getLastMessageId(output));
|
|
1926
2066
|
}
|
|
2067
|
+
await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
|
|
1927
2068
|
return {
|
|
1928
2069
|
content: [
|
|
1929
2070
|
{
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const AGENT_PRESENCE_STATUSES = [
|
|
2
|
+
"idle",
|
|
3
|
+
"working",
|
|
4
|
+
"reviewing",
|
|
5
|
+
"blocked",
|
|
6
|
+
];
|
|
7
|
+
export const AGENT_PRESENCE_FRESHNESS = [
|
|
8
|
+
"active",
|
|
9
|
+
"stale",
|
|
10
|
+
];
|
|
11
|
+
export const ACTIVE_AGENT_PRESENCE_WINDOW_MS = 90_000;
|
|
12
|
+
export function normalizeAgentPresenceStatus(value) {
|
|
13
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
14
|
+
return AGENT_PRESENCE_STATUSES.includes(normalized)
|
|
15
|
+
? normalized
|
|
16
|
+
: null;
|
|
17
|
+
}
|
|
18
|
+
export function getAgentPresenceFreshness(lastHeartbeatAt, now = Date.now()) {
|
|
19
|
+
const heartbeatTime = new Date(lastHeartbeatAt).getTime();
|
|
20
|
+
if (!Number.isFinite(heartbeatTime)) {
|
|
21
|
+
return "stale";
|
|
22
|
+
}
|
|
23
|
+
return now - heartbeatTime <= ACTIVE_AGENT_PRESENCE_WINDOW_MS
|
|
24
|
+
? "active"
|
|
25
|
+
: "stale";
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upper bound for `GET .../messages/poll` wait duration and MCP `wait_for_messages` timeout.
|
|
3
|
+
*
|
|
4
|
+
* Default **180000** (3 minutes). Operators may raise it (e.g. **36000000** for 10 hours) by
|
|
5
|
+
* setting **`LETAGENTS_POLL_MAX_MS`** on both the API process and the MCP server process.
|
|
6
|
+
* Values are clamped to **24 hours** to avoid accidental runaway timers.
|
|
7
|
+
*/
|
|
8
|
+
export function getPollTimeoutCapMs() {
|
|
9
|
+
const raw = process.env.LETAGENTS_POLL_MAX_MS;
|
|
10
|
+
if (raw == null || raw === "") {
|
|
11
|
+
return 180_000;
|
|
12
|
+
}
|
|
13
|
+
const n = Number.parseInt(String(raw), 10);
|
|
14
|
+
if (Number.isNaN(n) || n < 1_000) {
|
|
15
|
+
return 180_000;
|
|
16
|
+
}
|
|
17
|
+
const ceiling = 86_400_000; // 24 hours
|
|
18
|
+
return Math.min(n, ceiling);
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { parseAgentActorLabel } from "./agent-identity.js";
|
|
2
|
+
export const ROOM_PARTICIPANT_KINDS = ["human", "agent"];
|
|
3
|
+
function normalizeParticipantKeyPart(value) {
|
|
4
|
+
return String(value ?? "")
|
|
5
|
+
.trim()
|
|
6
|
+
.replace(/\s+/g, " ")
|
|
7
|
+
.toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
export function buildAgentRoomParticipantKey(actorLabel) {
|
|
10
|
+
const normalizedActorLabel = normalizeParticipantKeyPart(parseAgentActorLabel(actorLabel)?.raw ?? actorLabel);
|
|
11
|
+
return normalizedActorLabel ? `agent:${normalizedActorLabel}` : null;
|
|
12
|
+
}
|
|
13
|
+
export function buildHumanRoomParticipantKey(input) {
|
|
14
|
+
const loginKey = normalizeParticipantKeyPart(input.github_login);
|
|
15
|
+
if (loginKey) {
|
|
16
|
+
return `human:login:${loginKey}`;
|
|
17
|
+
}
|
|
18
|
+
const displayNameKey = normalizeParticipantKeyPart(input.display_name);
|
|
19
|
+
return displayNameKey ? `human:name:${displayNameKey}` : null;
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letagents",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.4",
|
|
4
4
|
"description": "Let Agents Chat — MCP server for AI agent communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/mcp/server.js",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"@types/pg": "^8.18.0",
|
|
49
49
|
"drizzle-kit": "^0.31.10",
|
|
50
50
|
"tsx": "^4.19.4",
|
|
51
|
-
"typescript": "^5.8.3"
|
|
51
|
+
"typescript": "^5.8.3",
|
|
52
|
+
"ws": "^8.20.0"
|
|
52
53
|
}
|
|
53
54
|
}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { findLetagentsConfig, getRoomFromConfig } from "../config-reader";
|
|
2
|
-
import { mkdirSync, writeFileSync, rmSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
4
|
-
import { tmpdir } from "os";
|
|
5
|
-
// Helper to create temp directories with config files
|
|
6
|
-
function createTempDir() {
|
|
7
|
-
const dir = join(tmpdir(), `letagents-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
8
|
-
mkdirSync(dir, { recursive: true });
|
|
9
|
-
return dir;
|
|
10
|
-
}
|
|
11
|
-
function cleanup(dir) {
|
|
12
|
-
try {
|
|
13
|
-
rmSync(dir, { recursive: true, force: true });
|
|
14
|
-
}
|
|
15
|
-
catch {
|
|
16
|
-
// ignore cleanup errors
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
describe("findLetagentsConfig", () => {
|
|
20
|
-
let tempDir;
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
if (tempDir)
|
|
23
|
-
cleanup(tempDir);
|
|
24
|
-
});
|
|
25
|
-
it("returns config when .letagents.json is in the start directory", () => {
|
|
26
|
-
tempDir = createTempDir();
|
|
27
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "github.com/EmmyMay/letagents" }));
|
|
28
|
-
const config = findLetagentsConfig(tempDir);
|
|
29
|
-
expect(config).toEqual({ room: "github.com/EmmyMay/letagents" });
|
|
30
|
-
});
|
|
31
|
-
it("walks up to find config in parent directory", () => {
|
|
32
|
-
tempDir = createTempDir();
|
|
33
|
-
const childDir = join(tempDir, "src", "mcp");
|
|
34
|
-
mkdirSync(childDir, { recursive: true });
|
|
35
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "gitlab.com/team/project" }));
|
|
36
|
-
const config = findLetagentsConfig(childDir);
|
|
37
|
-
expect(config).toEqual({ room: "gitlab.com/team/project" });
|
|
38
|
-
});
|
|
39
|
-
it("returns null when no config file exists", () => {
|
|
40
|
-
tempDir = createTempDir();
|
|
41
|
-
const config = findLetagentsConfig(tempDir);
|
|
42
|
-
expect(config).toBeNull();
|
|
43
|
-
});
|
|
44
|
-
it("returns null for config with missing room field", () => {
|
|
45
|
-
tempDir = createTempDir();
|
|
46
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ version: "1.0" }));
|
|
47
|
-
// Suppress console.error for this test
|
|
48
|
-
const spy = jest.spyOn(console, "error").mockImplementation();
|
|
49
|
-
const config = findLetagentsConfig(tempDir);
|
|
50
|
-
expect(config).toBeNull();
|
|
51
|
-
spy.mockRestore();
|
|
52
|
-
});
|
|
53
|
-
it("returns null for config with empty room field", () => {
|
|
54
|
-
tempDir = createTempDir();
|
|
55
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "" }));
|
|
56
|
-
const spy = jest.spyOn(console, "error").mockImplementation();
|
|
57
|
-
const config = findLetagentsConfig(tempDir);
|
|
58
|
-
expect(config).toBeNull();
|
|
59
|
-
spy.mockRestore();
|
|
60
|
-
});
|
|
61
|
-
it("returns null for invalid JSON", () => {
|
|
62
|
-
tempDir = createTempDir();
|
|
63
|
-
writeFileSync(join(tempDir, ".letagents.json"), "not valid json {{{");
|
|
64
|
-
const spy = jest.spyOn(console, "error").mockImplementation();
|
|
65
|
-
const config = findLetagentsConfig(tempDir);
|
|
66
|
-
expect(config).toBeNull();
|
|
67
|
-
spy.mockRestore();
|
|
68
|
-
});
|
|
69
|
-
it("trims whitespace from room name", () => {
|
|
70
|
-
tempDir = createTempDir();
|
|
71
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: " github.com/EmmyMay/letagents " }));
|
|
72
|
-
const config = findLetagentsConfig(tempDir);
|
|
73
|
-
expect(config).toEqual({ room: "github.com/EmmyMay/letagents" });
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
describe("getRoomFromConfig", () => {
|
|
77
|
-
let tempDir;
|
|
78
|
-
afterEach(() => {
|
|
79
|
-
if (tempDir)
|
|
80
|
-
cleanup(tempDir);
|
|
81
|
-
});
|
|
82
|
-
it("returns room string when config exists", () => {
|
|
83
|
-
tempDir = createTempDir();
|
|
84
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "github.com/EmmyMay/letagents" }));
|
|
85
|
-
const room = getRoomFromConfig(tempDir);
|
|
86
|
-
expect(room).toBe("github.com/EmmyMay/letagents");
|
|
87
|
-
});
|
|
88
|
-
it("returns null when no config exists", () => {
|
|
89
|
-
tempDir = createTempDir();
|
|
90
|
-
const room = getRoomFromConfig(tempDir);
|
|
91
|
-
expect(room).toBeNull();
|
|
92
|
-
});
|
|
93
|
-
});
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { normalizeGitRemote } from "../git-remote";
|
|
2
|
-
describe("normalizeGitRemote", () => {
|
|
3
|
-
// SSH format tests
|
|
4
|
-
it("normalizes SSH git@github.com format", () => {
|
|
5
|
-
expect(normalizeGitRemote("git@github.com:EmmyMay/letagents.git")).toBe("github.com/EmmyMay/letagents");
|
|
6
|
-
});
|
|
7
|
-
it("normalizes SSH without .git suffix", () => {
|
|
8
|
-
expect(normalizeGitRemote("git@github.com:EmmyMay/letagents")).toBe("github.com/EmmyMay/letagents");
|
|
9
|
-
});
|
|
10
|
-
it("normalizes SSH with gitlab host", () => {
|
|
11
|
-
expect(normalizeGitRemote("git@gitlab.com:team/project.git")).toBe("gitlab.com/team/project");
|
|
12
|
-
});
|
|
13
|
-
// HTTPS format tests
|
|
14
|
-
it("normalizes HTTPS with .git suffix", () => {
|
|
15
|
-
expect(normalizeGitRemote("https://github.com/EmmyMay/letagents.git")).toBe("github.com/EmmyMay/letagents");
|
|
16
|
-
});
|
|
17
|
-
it("normalizes HTTPS without .git suffix", () => {
|
|
18
|
-
expect(normalizeGitRemote("https://github.com/EmmyMay/letagents")).toBe("github.com/EmmyMay/letagents");
|
|
19
|
-
});
|
|
20
|
-
it("normalizes HTTPS with trailing slash", () => {
|
|
21
|
-
expect(normalizeGitRemote("https://github.com/EmmyMay/letagents/")).toBe("github.com/EmmyMay/letagents");
|
|
22
|
-
});
|
|
23
|
-
// SSH protocol format tests
|
|
24
|
-
it("normalizes ssh:// protocol format", () => {
|
|
25
|
-
expect(normalizeGitRemote("ssh://git@gitlab.com/team/project.git")).toBe("gitlab.com/team/project");
|
|
26
|
-
});
|
|
27
|
-
// Edge cases
|
|
28
|
-
it("handles whitespace", () => {
|
|
29
|
-
expect(normalizeGitRemote(" git@github.com:EmmyMay/letagents.git ")).toBe("github.com/EmmyMay/letagents");
|
|
30
|
-
});
|
|
31
|
-
it("handles nested paths", () => {
|
|
32
|
-
expect(normalizeGitRemote("https://gitlab.com/org/sub-group/project.git")).toBe("gitlab.com/org/sub-group/project");
|
|
33
|
-
});
|
|
34
|
-
it("handles Bitbucket SSH format", () => {
|
|
35
|
-
expect(normalizeGitRemote("git@bitbucket.org:workspace/repo.git")).toBe("bitbucket.org/workspace/repo");
|
|
36
|
-
});
|
|
37
|
-
});
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for server helper functions:
|
|
3
|
-
* - resolveGitRoot: resolves the root of a git repo from any subdirectory
|
|
4
|
-
* - findExistingConfig: walks parent dirs to find .letagents.json
|
|
5
|
-
*
|
|
6
|
-
* These helpers underpin the corrected initialize_repo tool behavior.
|
|
7
|
-
* @author Kingdavid Ehindero <kdof64squares@gmail.com>
|
|
8
|
-
*/
|
|
9
|
-
import { execSync } from "child_process";
|
|
10
|
-
import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
11
|
-
import { join, resolve } from "path";
|
|
12
|
-
import { tmpdir } from "os";
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// Re-implement helpers here (they are not exported from server.ts yet)
|
|
15
|
-
// We test the logic directly until we extract them to a shared module.
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
function resolveGitRoot(dir) {
|
|
18
|
-
try {
|
|
19
|
-
const root = execSync("git rev-parse --show-toplevel", {
|
|
20
|
-
cwd: dir,
|
|
21
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
22
|
-
encoding: "utf-8",
|
|
23
|
-
}).trim();
|
|
24
|
-
return root || null;
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
function findExistingConfig(startDir) {
|
|
31
|
-
const { dirname } = require("path");
|
|
32
|
-
let current = startDir;
|
|
33
|
-
while (true) {
|
|
34
|
-
if (existsSync(join(current, ".letagents.json")))
|
|
35
|
-
return current;
|
|
36
|
-
const parent = dirname(current);
|
|
37
|
-
if (parent === current)
|
|
38
|
-
break;
|
|
39
|
-
current = parent;
|
|
40
|
-
}
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
// Helpers for tests
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
function makeTempGitRepo() {
|
|
47
|
-
const dir = join(tmpdir(), `letagents-test-${Date.now()}`);
|
|
48
|
-
mkdirSync(dir, { recursive: true });
|
|
49
|
-
execSync("git init", { cwd: dir, stdio: "pipe" });
|
|
50
|
-
execSync("git commit --allow-empty -m init", { cwd: dir, stdio: "pipe" });
|
|
51
|
-
return dir;
|
|
52
|
-
}
|
|
53
|
-
function cleanup(dir) {
|
|
54
|
-
try {
|
|
55
|
-
rmSync(dir, { recursive: true, force: true });
|
|
56
|
-
}
|
|
57
|
-
catch { /* ignore */ }
|
|
58
|
-
}
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// resolveGitRoot tests
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
describe("resolveGitRoot", () => {
|
|
63
|
-
let repoDir;
|
|
64
|
-
beforeAll(() => { repoDir = makeTempGitRepo(); });
|
|
65
|
-
afterAll(() => cleanup(repoDir));
|
|
66
|
-
it("returns the repo root when called from repo root", () => {
|
|
67
|
-
const result = resolveGitRoot(repoDir);
|
|
68
|
-
expect(result).toBe(resolve(repoDir));
|
|
69
|
-
});
|
|
70
|
-
it("returns the repo root when called from a subdirectory", () => {
|
|
71
|
-
const subDir = join(repoDir, "src", "deep", "path");
|
|
72
|
-
mkdirSync(subDir, { recursive: true });
|
|
73
|
-
const result = resolveGitRoot(subDir);
|
|
74
|
-
expect(result).toBe(resolve(repoDir));
|
|
75
|
-
});
|
|
76
|
-
it("returns null when not inside a git repo", () => {
|
|
77
|
-
const nonRepoDir = join(tmpdir(), `no-git-${Date.now()}`);
|
|
78
|
-
mkdirSync(nonRepoDir, { recursive: true });
|
|
79
|
-
const result = resolveGitRoot(nonRepoDir);
|
|
80
|
-
cleanup(nonRepoDir);
|
|
81
|
-
expect(result).toBeNull();
|
|
82
|
-
});
|
|
83
|
-
it("returns null for a non-existent directory", () => {
|
|
84
|
-
const result = resolveGitRoot(join(tmpdir(), "does-not-exist-12345"));
|
|
85
|
-
expect(result).toBeNull();
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
// findExistingConfig tests
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
describe("findExistingConfig", () => {
|
|
92
|
-
let tempDir;
|
|
93
|
-
beforeEach(() => {
|
|
94
|
-
tempDir = join(tmpdir(), `letagents-cfg-test-${Date.now()}`);
|
|
95
|
-
mkdirSync(tempDir, { recursive: true });
|
|
96
|
-
});
|
|
97
|
-
afterEach(() => cleanup(tempDir));
|
|
98
|
-
it("returns null when no .letagents.json exists anywhere", () => {
|
|
99
|
-
const subDir = join(tempDir, "a", "b", "c");
|
|
100
|
-
mkdirSync(subDir, { recursive: true });
|
|
101
|
-
expect(findExistingConfig(subDir)).toBeNull();
|
|
102
|
-
});
|
|
103
|
-
it("finds config in the start directory", () => {
|
|
104
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "test" }));
|
|
105
|
-
expect(findExistingConfig(tempDir)).toBe(tempDir);
|
|
106
|
-
});
|
|
107
|
-
it("finds config in a parent directory when called from subdirectory", () => {
|
|
108
|
-
const subDir = join(tempDir, "nested", "path");
|
|
109
|
-
mkdirSync(subDir, { recursive: true });
|
|
110
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "test" }));
|
|
111
|
-
expect(findExistingConfig(subDir)).toBe(tempDir);
|
|
112
|
-
});
|
|
113
|
-
it("returns the closest config when multiple exist in the tree", () => {
|
|
114
|
-
const subDir = join(tempDir, "nested");
|
|
115
|
-
mkdirSync(subDir, { recursive: true });
|
|
116
|
-
// Config at root and at nested level — should find nested first
|
|
117
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "root" }));
|
|
118
|
-
writeFileSync(join(subDir, ".letagents.json"), JSON.stringify({ room: "nested" }));
|
|
119
|
-
expect(findExistingConfig(subDir)).toBe(subDir);
|
|
120
|
-
});
|
|
121
|
-
});
|