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 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
+ }
@@ -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({ status: "assigned", assignee: identity.actor_label }),
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 = status === "assigned" && !assignee
1559
- ? (getConversationIdentity(conversation_id) ?? await ensureAgentIdentity())
1560
- : null;
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: assignee ?? identity?.actor_label,
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: identity ? toPublicAgentIdentity(identity) : null,
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: _conversationId }) => {
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({ status: "in_review", pr_url }),
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: [{ type: "text", text: JSON.stringify({ success: true, task: updated }, null, 2) }],
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. Blocks until new messages arrive or 30 seconds elapse. Use the after_message_id parameter to only receive messages newer than a specific message.", {
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 serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), MAX_POLL_TIMEOUT_MS);
1883
- const clientTimeout = serverTimeout + 5000; // 5s buffer over server timeout
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.2",
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
- });