letagents 0.12.2 → 0.12.3

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.
@@ -0,0 +1,39 @@
1
+ const IDLE_STATUS_RE = /\b(idle|available|online|polling|monitoring|watching|ready)\b/i;
2
+ const REVIEWING_STATUS_RE = /\b(review|reviewing|approve|approval|approving)\b/i;
3
+ const BLOCKED_STATUS_RE = /\b(blocked|waiting|stuck)\b/i;
4
+ export function classifyPresenceStatusText(statusText, fallback = "working") {
5
+ const normalized = statusText.trim();
6
+ if (!normalized) {
7
+ return fallback;
8
+ }
9
+ if (BLOCKED_STATUS_RE.test(normalized)) {
10
+ return "blocked";
11
+ }
12
+ if (IDLE_STATUS_RE.test(normalized)) {
13
+ return "idle";
14
+ }
15
+ if (REVIEWING_STATUS_RE.test(normalized)) {
16
+ return "reviewing";
17
+ }
18
+ return "working";
19
+ }
20
+ export function deriveTaskPresenceStatus(taskStatus, fallback = "working") {
21
+ switch (taskStatus) {
22
+ case "blocked":
23
+ return "blocked";
24
+ case "in_review":
25
+ return "reviewing";
26
+ case "merged":
27
+ case "done":
28
+ case "accepted":
29
+ return "idle";
30
+ case "assigned":
31
+ case "in_progress":
32
+ return "working";
33
+ default:
34
+ return fallback;
35
+ }
36
+ }
37
+ export function getRoomIdentityPresenceCacheKey(roomId, actorLabel) {
38
+ return JSON.stringify([roomId, actorLabel]);
39
+ }
@@ -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,13 @@ 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";
19
21
  let currentRoom = null;
20
22
  let currentAgentIdentityKey = "";
21
23
  let currentAgentIdentity = null;
22
24
  let currentAuthenticatedAccount = undefined;
25
+ const roomPresenceByIdentity = new Map();
23
26
  // ---------------------------------------------------------------------------
24
27
  // Conversation-scoped identity (Option C: per-conversation hints)
25
28
  // ---------------------------------------------------------------------------
@@ -734,6 +737,42 @@ function touchCurrentRoom(lastMessageId) {
734
737
  }
735
738
  touchRoomSession(currentRoom.room_id, lastMessageId);
736
739
  }
740
+ function getRememberedRoomPresence(roomId, identity) {
741
+ if (!roomId || !identity) {
742
+ return { status: "idle", status_text: null };
743
+ }
744
+ return (roomPresenceByIdentity.get(getRoomIdentityPresenceCacheKey(roomId, identity.actor_label)) ?? { status: "idle", status_text: null });
745
+ }
746
+ async function syncRoomPresence(roomId, identity, presence) {
747
+ if (!roomId || !identity) {
748
+ return;
749
+ }
750
+ roomPresenceByIdentity.set(getRoomIdentityPresenceCacheKey(roomId, identity.actor_label), presence);
751
+ try {
752
+ await apiCall(`/rooms/${encodeRoomIdPath(roomId)}/presence`, {
753
+ method: "POST",
754
+ body: JSON.stringify({
755
+ actor_label: identity.actor_label,
756
+ agent_key: identity.canonical_key,
757
+ display_name: identity.display_name,
758
+ owner_label: identity.owner_label,
759
+ ide_label: identity.ide_label,
760
+ status: presence.status,
761
+ status_text: presence.status_text,
762
+ }),
763
+ });
764
+ touchRoomSession(roomId);
765
+ }
766
+ catch (error) {
767
+ if (isMissingRouteError(error)) {
768
+ return;
769
+ }
770
+ throw error;
771
+ }
772
+ }
773
+ async function heartbeatRoomPresence(roomId, identity) {
774
+ await syncRoomPresence(roomId, identity, getRememberedRoomPresence(roomId, identity));
775
+ }
737
776
  function getTargetRoomId(roomId) {
738
777
  return roomId || currentRoom?.room_id || null;
739
778
  }
@@ -827,6 +866,10 @@ async function joinRoomIdentifier(identifier, joinedVia) {
827
866
  joined_via: joinedVia,
828
867
  }));
829
868
  const agentIdentity = await ensureAgentIdentity();
869
+ await syncRoomPresence(room.room_id, agentIdentity, {
870
+ status: "idle",
871
+ status_text: "online in room",
872
+ });
830
873
  return {
831
874
  room,
832
875
  response: {
@@ -855,6 +898,10 @@ async function joinRoomIdentifier(identifier, joinedVia) {
855
898
  joined_via: joinedVia,
856
899
  }));
857
900
  const agentIdentity = await ensureAgentIdentity();
901
+ await syncRoomPresence(room.room_id, agentIdentity, {
902
+ status: "idle",
903
+ status_text: "online in room",
904
+ });
858
905
  return {
859
906
  room,
860
907
  response: {
@@ -883,6 +930,10 @@ async function joinRoomIdentifier(identifier, joinedVia) {
883
930
  joined_via: joinedVia,
884
931
  }));
885
932
  const agentIdentity = await ensureAgentIdentity();
933
+ await syncRoomPresence(room.room_id, agentIdentity, {
934
+ status: "idle",
935
+ status_text: "online in room",
936
+ });
886
937
  return {
887
938
  room,
888
939
  response: {
@@ -908,6 +959,10 @@ async function createInviteRoom() {
908
959
  joined_via: "join_code",
909
960
  }));
910
961
  const agentIdentity = await ensureAgentIdentity();
962
+ await syncRoomPresence(room.room_id, agentIdentity, {
963
+ status: "idle",
964
+ status_text: "online in room",
965
+ });
911
966
  return {
912
967
  room,
913
968
  response: {
@@ -1362,6 +1417,10 @@ server.tool("post_status", "Broadcast a lightweight status update to the current
1362
1417
  },
1363
1418
  });
1364
1419
  touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
1420
+ await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, {
1421
+ status: classifyPresenceStatusText(status, getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity).status),
1422
+ status_text: status,
1423
+ });
1365
1424
  return {
1366
1425
  content: [
1367
1426
  {
@@ -1420,6 +1479,7 @@ server.tool("add_task", "Add a new task to the room board. Tasks normally start
1420
1479
  }),
1421
1480
  },
1422
1481
  });
1482
+ await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
1423
1483
  return {
1424
1484
  content: [
1425
1485
  {
@@ -1471,10 +1531,49 @@ server.tool("get_board", "Get the current task board for the room. By default sh
1471
1531
  break;
1472
1532
  afterCursor = lastTask.id;
1473
1533
  }
1534
+ await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, await ensureAgentIdentity());
1474
1535
  return {
1475
1536
  content: [{ type: "text", text: JSON.stringify({ success: true, tasks: allTasks }, null, 2) }],
1476
1537
  };
1477
1538
  });
1539
+ server.tool("get_room_events", "Get GitHub events for the room (PRs, issues, reviews, check runs, etc.). " +
1540
+ "Returns a paginated list of normalized GitHub events persisted from webhooks. " +
1541
+ "Use this to check what happened in the repo without parsing chat messages.", {
1542
+ event_type: z.string().optional().describe("Filter by event type: pull_request, issue, issue_comment, pull_request_review, check_run, installation, installation_repositories, repository"),
1543
+ object_id: z.string().optional().describe("Filter by GitHub object ID (e.g. PR number, issue number)"),
1544
+ actor: z.string().optional().describe("Filter by GitHub login of the actor"),
1545
+ since: z.string().optional().describe("ISO timestamp — only events after this time"),
1546
+ until: z.string().optional().describe("ISO timestamp — only events before this time"),
1547
+ after: z.string().optional().describe("Cursor event ID for pagination (from a previous response)"),
1548
+ limit: z.number().int().min(1).max(100).optional().describe("Max events to return (default 50, max 100)"),
1549
+ room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
1550
+ }, async ({ event_type, object_id, actor, since, until, after, limit, room_id }) => {
1551
+ const targetRoomId = getTargetRoomId(room_id);
1552
+ const targetProjectId = getFallbackProjectId();
1553
+ if (!targetRoomId && !targetProjectId) {
1554
+ return {
1555
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "Not in a room. Join one first." }) }],
1556
+ };
1557
+ }
1558
+ const qs = buildRoomEventsQueryString({
1559
+ event_type,
1560
+ object_id,
1561
+ actor,
1562
+ since,
1563
+ until,
1564
+ after,
1565
+ limit,
1566
+ });
1567
+ const result = await roomScopedApiCall({
1568
+ room_id: targetRoomId,
1569
+ project_id: targetProjectId,
1570
+ room_path: (targetRoomId) => `/rooms/${encodeRoomIdPath(targetRoomId)}/events${qs ? `?${qs}` : ""}`,
1571
+ project_path: (targetProjectId) => `/rooms/${encodeURIComponent(targetProjectId)}/events${qs ? `?${qs}` : ""}`,
1572
+ });
1573
+ return {
1574
+ content: [{ type: "text", text: JSON.stringify({ success: true, events: result.events ?? [], has_more: result.has_more ?? false }, null, 2) }],
1575
+ };
1576
+ });
1478
1577
  server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted' " +
1479
1578
  "status. This sets the assignee to you and moves the status to 'assigned'. " +
1480
1579
  "Do NOT claim proposed tasks — they need to be accepted first.", {
@@ -1505,6 +1604,10 @@ server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted
1505
1604
  body: JSON.stringify({ status: "assigned", assignee: identity.actor_label }),
1506
1605
  },
1507
1606
  });
1607
+ await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, {
1608
+ status: "working",
1609
+ status_text: `claimed ${task_id}`,
1610
+ });
1508
1611
  return {
1509
1612
  content: [
1510
1613
  {
@@ -1555,9 +1658,7 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
1555
1658
  };
1556
1659
  }
1557
1660
  try {
1558
- const identity = status === "assigned" && !assignee
1559
- ? (getConversationIdentity(conversation_id) ?? await ensureAgentIdentity())
1560
- : null;
1661
+ const identity = getConversationIdentity(conversation_id) ?? await ensureAgentIdentity();
1561
1662
  const updated = await roomScopedApiCall({
1562
1663
  room_id: targetRoomId,
1563
1664
  project_id: targetProjectId,
@@ -1567,12 +1668,18 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
1567
1668
  method: "PATCH",
1568
1669
  body: JSON.stringify({
1569
1670
  status,
1570
- assignee: assignee ?? identity?.actor_label,
1671
+ assignee: status === "assigned" && !assignee ? identity.actor_label : assignee,
1571
1672
  pr_url,
1572
1673
  workflow_artifacts,
1573
1674
  }),
1574
1675
  },
1575
1676
  });
1677
+ await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, {
1678
+ status: deriveTaskPresenceStatus(status ?? null, getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity).status),
1679
+ status_text: status
1680
+ ? `${task_id} -> ${status}`
1681
+ : getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity).status_text,
1682
+ });
1576
1683
  return {
1577
1684
  content: [
1578
1685
  {
@@ -1580,7 +1687,7 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
1580
1687
  text: JSON.stringify({
1581
1688
  success: true,
1582
1689
  task: updated,
1583
- agent_identity: identity ? toPublicAgentIdentity(identity) : null,
1690
+ agent_identity: toPublicAgentIdentity(identity),
1584
1691
  }, null, 2),
1585
1692
  },
1586
1693
  ],
@@ -1806,6 +1913,7 @@ server.tool("send_message", "Send a message to a Let Agents Chat room.", {
1806
1913
  },
1807
1914
  });
1808
1915
  touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
1916
+ await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
1809
1917
  return {
1810
1918
  content: [
1811
1919
  {
@@ -1850,6 +1958,7 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
1850
1958
  break;
1851
1959
  afterCursor = lastMsg.id;
1852
1960
  }
1961
+ await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, await ensureAgentIdentity());
1853
1962
  const output = { messages: toAgentReadableMessages(allMessages) };
1854
1963
  if (roomIdFromResponse) {
1855
1964
  output[targetRoomId ? "room_id" : "project_id"] = roomIdFromResponse;
@@ -1879,6 +1988,8 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat roo
1879
1988
  }, async ({ room_id, after_message_id, timeout }) => {
1880
1989
  const targetRoomId = getTargetRoomId(room_id);
1881
1990
  const targetProjectId = getFallbackProjectId();
1991
+ const identity = await ensureAgentIdentity();
1992
+ await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
1882
1993
  const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), MAX_POLL_TIMEOUT_MS);
1883
1994
  const clientTimeout = serverTimeout + 5000; // 5s buffer over server timeout
1884
1995
  const params = new URLSearchParams();
@@ -1924,6 +2035,7 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat roo
1924
2035
  if (targetRoomId) {
1925
2036
  touchRoomSession(targetRoomId, getLastMessageId(output));
1926
2037
  }
2038
+ await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
1927
2039
  return {
1928
2040
  content: [
1929
2041
  {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letagents",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
4
4
  "description": "Let Agents Chat — MCP server for AI agent communication",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/server.js",