letagents 0.12.1 → 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
  {
@@ -1530,9 +1633,23 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
1530
1633
  .optional()
1531
1634
  .describe("New assignee for the task. Defaults to the current agent when status=assigned."),
1532
1635
  pr_url: z.string().optional().describe("PR URL to link to the task"),
1636
+ workflow_artifacts: z
1637
+ .array(z.object({
1638
+ provider: z.enum(["github", "gitlab", "bitbucket", "unknown"]),
1639
+ kind: z.enum(["issue", "branch", "pull_request", "merge_request", "review", "check_run", "merge"]),
1640
+ id: z.string().nullable().optional(),
1641
+ number: z.number().int().nullable().optional(),
1642
+ title: z.string().nullable().optional(),
1643
+ url: z.string().nullable().optional(),
1644
+ ref: z.string().nullable().optional(),
1645
+ state: z.string().nullable().optional(),
1646
+ }).strict())
1647
+ .max(32)
1648
+ .optional()
1649
+ .describe("Persisted provider-neutral task workflow artifacts to attach to the task"),
1533
1650
  room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
1534
1651
  conversation_id: z.string().optional().describe("Optional conversation ID for per-conversation identity scoping."),
1535
- }, async ({ task_id, status, assignee, pr_url, room_id, conversation_id }) => {
1652
+ }, async ({ task_id, status, assignee, pr_url, workflow_artifacts, room_id, conversation_id }) => {
1536
1653
  const targetRoomId = getTargetRoomId(room_id);
1537
1654
  const targetProjectId = getFallbackProjectId();
1538
1655
  if (!targetRoomId && !targetProjectId) {
@@ -1541,9 +1658,7 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
1541
1658
  };
1542
1659
  }
1543
1660
  try {
1544
- const identity = status === "assigned" && !assignee
1545
- ? (getConversationIdentity(conversation_id) ?? await ensureAgentIdentity())
1546
- : null;
1661
+ const identity = getConversationIdentity(conversation_id) ?? await ensureAgentIdentity();
1547
1662
  const updated = await roomScopedApiCall({
1548
1663
  room_id: targetRoomId,
1549
1664
  project_id: targetProjectId,
@@ -1553,11 +1668,18 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
1553
1668
  method: "PATCH",
1554
1669
  body: JSON.stringify({
1555
1670
  status,
1556
- assignee: assignee ?? identity?.actor_label,
1671
+ assignee: status === "assigned" && !assignee ? identity.actor_label : assignee,
1557
1672
  pr_url,
1673
+ workflow_artifacts,
1558
1674
  }),
1559
1675
  },
1560
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
+ });
1561
1683
  return {
1562
1684
  content: [
1563
1685
  {
@@ -1565,7 +1687,7 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
1565
1687
  text: JSON.stringify({
1566
1688
  success: true,
1567
1689
  task: updated,
1568
- agent_identity: identity ? toPublicAgentIdentity(identity) : null,
1690
+ agent_identity: toPublicAgentIdentity(identity),
1569
1691
  }, null, 2),
1570
1692
  },
1571
1693
  ],
@@ -1765,11 +1887,15 @@ server.tool("send_message", "Send a message to a Let Agents Chat room.", {
1765
1887
  .optional()
1766
1888
  .describe("Deprecated override. Agent identity is resolved automatically on room entry."),
1767
1889
  text: z.string().describe("The message text to send"),
1890
+ reply_to: z
1891
+ .string()
1892
+ .optional()
1893
+ .describe("Optional message id to quote-reply to (for example `msg_42`)."),
1768
1894
  conversation_id: z
1769
1895
  .string()
1770
1896
  .optional()
1771
1897
  .describe("Optional conversation ID for per-conversation identity scoping."),
1772
- }, async ({ room_id, sender: _sender, text, conversation_id }) => {
1898
+ }, async ({ room_id, sender: _sender, text, reply_to, conversation_id }) => {
1773
1899
  const targetRoomId = getTargetRoomId(room_id);
1774
1900
  const targetProjectId = getFallbackProjectId();
1775
1901
  if (!targetRoomId && !targetProjectId) {
@@ -1783,10 +1909,11 @@ server.tool("send_message", "Send a message to a Let Agents Chat room.", {
1783
1909
  project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/messages`,
1784
1910
  options: {
1785
1911
  method: "POST",
1786
- body: JSON.stringify({ sender: identity.actor_label, text }),
1912
+ body: JSON.stringify({ sender: identity.actor_label, text, reply_to }),
1787
1913
  },
1788
1914
  });
1789
1915
  touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
1916
+ await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
1790
1917
  return {
1791
1918
  content: [
1792
1919
  {
@@ -1831,6 +1958,7 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
1831
1958
  break;
1832
1959
  afterCursor = lastMsg.id;
1833
1960
  }
1961
+ await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, await ensureAgentIdentity());
1834
1962
  const output = { messages: toAgentReadableMessages(allMessages) };
1835
1963
  if (roomIdFromResponse) {
1836
1964
  output[targetRoomId ? "room_id" : "project_id"] = roomIdFromResponse;
@@ -1860,6 +1988,8 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat roo
1860
1988
  }, async ({ room_id, after_message_id, timeout }) => {
1861
1989
  const targetRoomId = getTargetRoomId(room_id);
1862
1990
  const targetProjectId = getFallbackProjectId();
1991
+ const identity = await ensureAgentIdentity();
1992
+ await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
1863
1993
  const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), MAX_POLL_TIMEOUT_MS);
1864
1994
  const clientTimeout = serverTimeout + 5000; // 5s buffer over server timeout
1865
1995
  const params = new URLSearchParams();
@@ -1905,6 +2035,7 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat roo
1905
2035
  if (targetRoomId) {
1906
2036
  touchRoomSession(targetRoomId, getLastMessageId(output));
1907
2037
  }
2038
+ await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
1908
2039
  return {
1909
2040
  content: [
1910
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.1",
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",