agent-relay 6.0.22 → 6.2.1

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.
Files changed (117) hide show
  1. package/dist/index.cjs +338 -49
  2. package/dist/src/cli/bootstrap.d.ts.map +1 -1
  3. package/dist/src/cli/bootstrap.js +62 -0
  4. package/dist/src/cli/bootstrap.js.map +1 -1
  5. package/dist/src/cli/commands/agent-management.d.ts +8 -0
  6. package/dist/src/cli/commands/agent-management.d.ts.map +1 -1
  7. package/dist/src/cli/commands/agent-management.js +34 -0
  8. package/dist/src/cli/commands/agent-management.js.map +1 -1
  9. package/dist/src/cli/commands/drive.d.ts +222 -0
  10. package/dist/src/cli/commands/drive.d.ts.map +1 -0
  11. package/dist/src/cli/commands/drive.js +565 -0
  12. package/dist/src/cli/commands/drive.js.map +1 -0
  13. package/dist/src/cli/commands/messaging.d.ts +25 -0
  14. package/dist/src/cli/commands/messaging.d.ts.map +1 -1
  15. package/dist/src/cli/commands/messaging.js +702 -138
  16. package/dist/src/cli/commands/messaging.js.map +1 -1
  17. package/dist/src/cli/commands/new.d.ts +112 -0
  18. package/dist/src/cli/commands/new.d.ts.map +1 -0
  19. package/dist/src/cli/commands/new.js +189 -0
  20. package/dist/src/cli/commands/new.js.map +1 -0
  21. package/dist/src/cli/commands/on/provision.d.ts +1 -1
  22. package/dist/src/cli/commands/on/provision.d.ts.map +1 -1
  23. package/dist/src/cli/commands/on/provision.js +1 -1
  24. package/dist/src/cli/commands/on/provision.js.map +1 -1
  25. package/dist/src/cli/commands/on/start.d.ts +1 -1
  26. package/dist/src/cli/commands/on/start.d.ts.map +1 -1
  27. package/dist/src/cli/commands/on/start.js +2 -2
  28. package/dist/src/cli/commands/on/start.js.map +1 -1
  29. package/dist/src/cli/commands/passthrough.d.ts +142 -0
  30. package/dist/src/cli/commands/passthrough.d.ts.map +1 -0
  31. package/dist/src/cli/commands/passthrough.js +398 -0
  32. package/dist/src/cli/commands/passthrough.js.map +1 -0
  33. package/dist/src/cli/commands/rm.d.ts +52 -0
  34. package/dist/src/cli/commands/rm.d.ts.map +1 -0
  35. package/dist/src/cli/commands/rm.js +96 -0
  36. package/dist/src/cli/commands/rm.js.map +1 -0
  37. package/dist/src/cli/commands/view.d.ts +98 -0
  38. package/dist/src/cli/commands/view.d.ts.map +1 -0
  39. package/dist/src/cli/commands/view.js +238 -0
  40. package/dist/src/cli/commands/view.js.map +1 -0
  41. package/dist/src/cli/lib/agent-management-listing.d.ts +35 -7
  42. package/dist/src/cli/lib/agent-management-listing.d.ts.map +1 -1
  43. package/dist/src/cli/lib/agent-management-listing.js +188 -40
  44. package/dist/src/cli/lib/agent-management-listing.js.map +1 -1
  45. package/dist/src/cli/lib/attach.d.ts +56 -0
  46. package/dist/src/cli/lib/attach.d.ts.map +1 -0
  47. package/dist/src/cli/lib/attach.js +73 -0
  48. package/dist/src/cli/lib/attach.js.map +1 -0
  49. package/dist/src/cli/lib/broker-connection.d.ts +40 -0
  50. package/dist/src/cli/lib/broker-connection.d.ts.map +1 -0
  51. package/dist/src/cli/lib/broker-connection.js +82 -0
  52. package/dist/src/cli/lib/broker-connection.js.map +1 -0
  53. package/dist/src/cli/lib/formatting.d.ts +4 -0
  54. package/dist/src/cli/lib/formatting.d.ts.map +1 -1
  55. package/dist/src/cli/lib/formatting.js +31 -1
  56. package/dist/src/cli/lib/formatting.js.map +1 -1
  57. package/dist/src/cli/lib/sdk-client.d.ts +9 -0
  58. package/dist/src/cli/lib/sdk-client.d.ts.map +1 -0
  59. package/dist/src/cli/lib/sdk-client.js +28 -0
  60. package/dist/src/cli/lib/sdk-client.js.map +1 -0
  61. package/dist/src/cli/lib/spawn-and-attach.d.ts +132 -0
  62. package/dist/src/cli/lib/spawn-and-attach.d.ts.map +1 -0
  63. package/dist/src/cli/lib/spawn-and-attach.js +334 -0
  64. package/dist/src/cli/lib/spawn-and-attach.js.map +1 -0
  65. package/package.json +12 -10
  66. package/dist/packages/cloud/src/api-client.d.ts +0 -33
  67. package/dist/packages/cloud/src/api-client.d.ts.map +0 -1
  68. package/dist/packages/cloud/src/api-client.js +0 -123
  69. package/dist/packages/cloud/src/api-client.js.map +0 -1
  70. package/dist/packages/cloud/src/auth.d.ts +0 -13
  71. package/dist/packages/cloud/src/auth.d.ts.map +0 -1
  72. package/dist/packages/cloud/src/auth.js +0 -299
  73. package/dist/packages/cloud/src/auth.js.map +0 -1
  74. package/dist/packages/cloud/src/connect.d.ts +0 -45
  75. package/dist/packages/cloud/src/connect.d.ts.map +0 -1
  76. package/dist/packages/cloud/src/connect.js +0 -166
  77. package/dist/packages/cloud/src/connect.js.map +0 -1
  78. package/dist/packages/cloud/src/index.d.ts +0 -10
  79. package/dist/packages/cloud/src/index.d.ts.map +0 -1
  80. package/dist/packages/cloud/src/index.js +0 -10
  81. package/dist/packages/cloud/src/index.js.map +0 -1
  82. package/dist/packages/cloud/src/lib/ssh-interactive.d.ts +0 -70
  83. package/dist/packages/cloud/src/lib/ssh-interactive.d.ts.map +0 -1
  84. package/dist/packages/cloud/src/lib/ssh-interactive.js +0 -440
  85. package/dist/packages/cloud/src/lib/ssh-interactive.js.map +0 -1
  86. package/dist/packages/cloud/src/lib/ssh-runtime.d.ts +0 -35
  87. package/dist/packages/cloud/src/lib/ssh-runtime.d.ts.map +0 -1
  88. package/dist/packages/cloud/src/lib/ssh-runtime.js +0 -52
  89. package/dist/packages/cloud/src/lib/ssh-runtime.js.map +0 -1
  90. package/dist/packages/cloud/src/proactive-runtime.d.ts +0 -24
  91. package/dist/packages/cloud/src/proactive-runtime.d.ts.map +0 -1
  92. package/dist/packages/cloud/src/proactive-runtime.js +0 -315
  93. package/dist/packages/cloud/src/proactive-runtime.js.map +0 -1
  94. package/dist/packages/cloud/src/types.d.ts +0 -200
  95. package/dist/packages/cloud/src/types.d.ts.map +0 -1
  96. package/dist/packages/cloud/src/types.js +0 -12
  97. package/dist/packages/cloud/src/types.js.map +0 -1
  98. package/dist/packages/cloud/src/workflows.d.ts +0 -65
  99. package/dist/packages/cloud/src/workflows.d.ts.map +0 -1
  100. package/dist/packages/cloud/src/workflows.js +0 -892
  101. package/dist/packages/cloud/src/workflows.js.map +0 -1
  102. package/dist/packages/cloud/src/workspaces.d.ts +0 -11
  103. package/dist/packages/cloud/src/workspaces.d.ts.map +0 -1
  104. package/dist/packages/cloud/src/workspaces.js +0 -146
  105. package/dist/packages/cloud/src/workspaces.js.map +0 -1
  106. package/dist/packages/sdk/src/provisioner/local-jwks.d.ts +0 -25
  107. package/dist/packages/sdk/src/provisioner/local-jwks.d.ts.map +0 -1
  108. package/dist/packages/sdk/src/provisioner/local-jwks.js +0 -70
  109. package/dist/packages/sdk/src/provisioner/local-jwks.js.map +0 -1
  110. package/dist/packages/sdk/src/provisioner/seeder.d.ts +0 -17
  111. package/dist/packages/sdk/src/provisioner/seeder.d.ts.map +0 -1
  112. package/dist/packages/sdk/src/provisioner/seeder.js +0 -419
  113. package/dist/packages/sdk/src/provisioner/seeder.js.map +0 -1
  114. package/dist/packages/sdk/src/provisioner/token.d.ts +0 -41
  115. package/dist/packages/sdk/src/provisioner/token.d.ts.map +0 -1
  116. package/dist/packages/sdk/src/provisioner/token.js +0 -77
  117. package/dist/packages/sdk/src/provisioner/token.js.map +0 -1
@@ -1,7 +1,14 @@
1
1
  import { RelayCast, AgentRelayClient } from '@agent-relay/sdk';
2
2
  import { getProjectPaths } from '@agent-relay/config';
3
3
  import { defaultExit } from '../lib/exit.js';
4
- import { parseSince } from '../lib/formatting.js';
4
+ import { parseSince, sanitizeForTerminal, sanitizeForTerminalLine } from '../lib/formatting.js';
5
+ const MAX_DM_FETCH_LIMIT = 1000;
6
+ const MARK_READ_CONCURRENCY = 8;
7
+ const HISTORY_DM_CONVERSATION_SCAN_LIMIT = 50;
8
+ const HISTORY_DM_PER_CONVERSATION_FETCH_LIMIT = 100;
9
+ const INBOX_UNREAD_DM_FETCH_CONVERSATION_LIMIT = 50;
10
+ const INBOX_DM_PREVIEW_FETCH_LIMIT = 10;
11
+ const FALLBACK_DM_CREATED_AT = '1970-01-01T00:00:00.000Z';
5
12
  function isPresent(value) {
6
13
  return value !== null && value !== undefined;
7
14
  }
@@ -13,6 +20,9 @@ function readString(...values) {
13
20
  }
14
21
  return undefined;
15
22
  }
23
+ function readText(value) {
24
+ return typeof value === 'string' ? value : '';
25
+ }
16
26
  function readNumber(...values) {
17
27
  for (const value of values) {
18
28
  if (typeof value === 'number' && Number.isFinite(value)) {
@@ -21,6 +31,149 @@ function readNumber(...values) {
21
31
  }
22
32
  return undefined;
23
33
  }
34
+ function readBoolean(...values) {
35
+ for (const value of values) {
36
+ if (typeof value === 'boolean') {
37
+ return value;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+ function formatErrorDetail(err) {
43
+ return sanitizeForTerminalLine(err instanceof Error ? err.message : String(err));
44
+ }
45
+ function getDefaultOrchestratorName() {
46
+ return process.env.AGENT_RELAY_ORCHESTRATOR_NAME?.trim() || 'orchestrator';
47
+ }
48
+ function getAllowedReadAgentNames() {
49
+ const allowed = new Set([getDefaultOrchestratorName()]);
50
+ for (const name of (process.env.AGENT_RELAY_ALLOWED_READ_IDENTITIES ?? '').split(',')) {
51
+ const trimmed = name.trim();
52
+ if (trimmed) {
53
+ allowed.add(trimmed);
54
+ }
55
+ }
56
+ return allowed;
57
+ }
58
+ function isAuthorizedReadIdentity(agentName) {
59
+ return getAllowedReadAgentNames().has(agentName);
60
+ }
61
+ function reportUnauthorizedReadIdentity(deps, agentName) {
62
+ deps.error(`Refusing to read as ${sanitizeForTerminalLine(agentName)}: read identities must be the configured orchestrator or listed in AGENT_RELAY_ALLOWED_READ_IDENTITIES.`);
63
+ deps.exit(1);
64
+ }
65
+ function parseMessageLimit(value, defaultValue = 50) {
66
+ if (value === undefined) {
67
+ return defaultValue;
68
+ }
69
+ const trimmed = String(value).trim();
70
+ if (!/^\d+$/.test(trimmed)) {
71
+ throw new Error(`Invalid --limit value: ${value}`);
72
+ }
73
+ const parsed = Number(trimmed);
74
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
75
+ throw new Error(`Invalid --limit value: ${value}`);
76
+ }
77
+ return Math.min(parsed, MAX_DM_FETCH_LIMIT);
78
+ }
79
+ /**
80
+ * `--since` accepts either a time (`5m`, `1h`, ISO-8601) or a message-id
81
+ * cursor. A cursor returns only messages strictly AFTER that id in
82
+ * chronological order, so a polling caller (`--since <lastId>`) never
83
+ * re-receives a message it already saw — the spec's stale-replay fix.
84
+ */
85
+ function resolveSince(value) {
86
+ if (value === undefined || !String(value).trim())
87
+ return { kind: 'none' };
88
+ const trimmed = String(value).trim();
89
+ const ts = parseSince(trimmed);
90
+ if (ts !== undefined)
91
+ return { kind: 'time', ts };
92
+ if (/^-\d+(?:[smhd]|sec|secs|second|seconds|min|mins|minute|minutes|hour|hours|day|days)$/i.test(trimmed)) {
93
+ throw new Error(`Invalid --since value: ${value}`);
94
+ }
95
+ if (/^\d+(?:sec|secs|second|seconds|min|mins|minute|minutes|hour|hours|day|days)$/i.test(trimmed)) {
96
+ throw new Error(`Invalid --since value: ${value}`);
97
+ }
98
+ // Not a time. Treat as an opaque message-id cursor. Whitespace means it is
99
+ // neither a valid time nor a plausible id — surface the original error.
100
+ if (/\s/.test(trimmed)) {
101
+ throw new Error(`Invalid --since value: ${value}`);
102
+ }
103
+ return { kind: 'cursor', id: trimmed };
104
+ }
105
+ /**
106
+ * Drop everything up to and including the cursor message. If the cursor id
107
+ * is not in the (already chronologically sorted) window, return the available
108
+ * window so callers do not silently miss a burst that pushed the cursor out of
109
+ * the bounded fetch. `messages` MUST be sorted oldest→newest.
110
+ */
111
+ function sliceAfterCursor(messages, cursorId) {
112
+ let cut = -1;
113
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
114
+ if (messages[i].id === cursorId) {
115
+ cut = i;
116
+ break;
117
+ }
118
+ }
119
+ if (cut === -1)
120
+ return messages;
121
+ return messages.slice(cut + 1);
122
+ }
123
+ function normalizeUnreadCount(value) {
124
+ if (!Number.isFinite(value) || value === undefined || value < 0) {
125
+ return 0;
126
+ }
127
+ return Math.floor(value);
128
+ }
129
+ function shellQuote(value) {
130
+ return `'${value.replaceAll("'", "'\\''")}'`;
131
+ }
132
+ function renderShellQuotedForTerminal(value) {
133
+ return shellQuote(sanitizeForTerminalLine(value));
134
+ }
135
+ async function disconnectRelaycastClient(client) {
136
+ try {
137
+ await client?.disconnect?.();
138
+ }
139
+ catch {
140
+ // Best-effort cleanup must not mask the command result.
141
+ }
142
+ }
143
+ function getDmParticipantName(participant) {
144
+ return readString(participant.agentName, participant.agent_name);
145
+ }
146
+ function getDmParticipantNames(conversation) {
147
+ return conversation.participants.map(getDmParticipantName).filter(isPresent);
148
+ }
149
+ const IMPLICIT_DM_PARTICIPANT_NAMES = new Set(['system', 'relay', 'relaycast', 'bot']);
150
+ function isImplicitDmParticipant(name) {
151
+ return IMPLICIT_DM_PARTICIPANT_NAMES.has(name.trim().toLowerCase());
152
+ }
153
+ function hasCompatibleDirectDmParticipants(conversation, readerName, agentName, allowReaderOmitted) {
154
+ const participantNames = new Set(getDmParticipantNames(conversation));
155
+ if (!participantNames.has(agentName)) {
156
+ return false;
157
+ }
158
+ const hasReader = participantNames.has(readerName);
159
+ if (!hasReader && !allowReaderOmitted) {
160
+ return false;
161
+ }
162
+ if (!hasReader && allowReaderOmitted) {
163
+ return [...participantNames].every((name) => name === agentName || isImplicitDmParticipant(name));
164
+ }
165
+ return [...participantNames].every((name) => name === readerName || name === agentName || isImplicitDmParticipant(name));
166
+ }
167
+ function findDirectDmConversation(conversations, readerName, agentName) {
168
+ return (conversations.find((conversation) => {
169
+ const dmType = readString(conversation.type, conversation.dm_type);
170
+ return dmType === '1:1' && hasCompatibleDirectDmParticipants(conversation, readerName, agentName, true);
171
+ }) ??
172
+ conversations.find((conversation) => {
173
+ const dmType = readString(conversation.type, conversation.dm_type);
174
+ return !dmType && hasCompatibleDirectDmParticipants(conversation, readerName, agentName, false);
175
+ }));
176
+ }
24
177
  function normalizeIsoTimestamp(value) {
25
178
  if (typeof value !== 'string' || !value.trim()) {
26
179
  return undefined;
@@ -40,10 +193,89 @@ function normalizeMessage(message) {
40
193
  return {
41
194
  id: message.id,
42
195
  agentName,
43
- text: message.text,
196
+ text: readText(message.text),
197
+ createdAt,
198
+ };
199
+ }
200
+ function normalizeDmMessage(message, defaults = {}) {
201
+ const agentName = readString(message.agentName, message.agent_name) ?? defaults.agentName;
202
+ const createdAt = normalizeIsoTimestamp(readString(message.createdAt, message.created_at)) ?? defaults.createdAt;
203
+ if (!agentName || !createdAt) {
204
+ return null;
205
+ }
206
+ const explicitUnread = readBoolean(message.unread, message.isUnread, message.is_unread);
207
+ const explicitRead = readBoolean(message.read, message.isRead, message.is_read);
208
+ return {
209
+ id: message.id,
210
+ agentName,
211
+ text: readText(message.text),
44
212
  createdAt,
213
+ unread: explicitUnread ?? (explicitRead === undefined ? undefined : !explicitRead),
45
214
  };
46
215
  }
216
+ function sortDmMessagesChronologically(messages) {
217
+ return [...messages].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
218
+ }
219
+ function renderTranscriptMessage(log, message) {
220
+ const agentName = sanitizeForTerminalLine(message.agentName);
221
+ const createdAt = sanitizeForTerminalLine(message.createdAt);
222
+ const lines = message.text.split(/\r?\n/).map(sanitizeForTerminal);
223
+ if (lines.length === 1) {
224
+ log(`[${createdAt}] ${agentName}: ${lines[0]}`);
225
+ return;
226
+ }
227
+ log(`[${createdAt}] ${agentName}:`);
228
+ for (const line of lines) {
229
+ log(` ${line}`);
230
+ }
231
+ }
232
+ function isInboundUnreadDmMessage(message, senderName) {
233
+ return message.agentName === senderName;
234
+ }
235
+ function sortUnreadDmMessagesMostRecentFirst(messages) {
236
+ return [...messages].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
237
+ }
238
+ function getFilteredDmFetchLimit(displayLimit, unreadCount = 0) {
239
+ return Math.min(Math.max(displayLimit * 2, normalizeUnreadCount(unreadCount), 100), MAX_DM_FETCH_LIMIT);
240
+ }
241
+ function selectUnreadCandidates(messages, unreadCount) {
242
+ const normalizedUnreadCount = normalizeUnreadCount(unreadCount);
243
+ const explicitUnread = messages.filter((message) => message.unread === true);
244
+ if (explicitUnread.length > 0 || normalizedUnreadCount === 0) {
245
+ return explicitUnread;
246
+ }
247
+ const unknownReadState = messages.filter((message) => message.unread === undefined);
248
+ if (unknownReadState.length === 0) {
249
+ return [];
250
+ }
251
+ return sortDmMessagesChronologically(unknownReadState).slice(-normalizedUnreadCount);
252
+ }
253
+ function renderUnreadDmMessage(log, message, senderName) {
254
+ if (message.diagnostic) {
255
+ log(` ${sanitizeForTerminalLine(message.diagnostic)}`);
256
+ return;
257
+ }
258
+ const lines = message.text.split(/\r?\n/).map(sanitizeForTerminal);
259
+ const displaySender = sanitizeForTerminalLine(message.agentName || senderName);
260
+ const createdAt = sanitizeForTerminalLine(message.createdAt);
261
+ if (lines.length === 1) {
262
+ log(` [${createdAt}] ${displaySender}: ${lines[0]}`);
263
+ return;
264
+ }
265
+ log(` [${createdAt}] ${displaySender}:`);
266
+ for (const line of lines) {
267
+ log(` ${line}`);
268
+ }
269
+ }
270
+ function getLastMessageDirection(message, dmFrom, readerName) {
271
+ if (message.agentName === dmFrom) {
272
+ return 'inbound';
273
+ }
274
+ if (message.agentName === readerName) {
275
+ return 'outbound';
276
+ }
277
+ return message.agentName ? 'outbound' : 'unknown';
278
+ }
47
279
  function normalizeUnreadChannel(channel) {
48
280
  const channelName = readString(channel.channelName, channel.channel_name);
49
281
  const unreadCount = readNumber(channel.unreadCount, channel.unread_count);
@@ -66,7 +298,7 @@ function normalizeMention(mention) {
66
298
  id: mention.id,
67
299
  channelName,
68
300
  agentName,
69
- text: mention.text,
301
+ text: readText(mention.text),
70
302
  createdAt,
71
303
  };
72
304
  }
@@ -78,23 +310,32 @@ function normalizeLastMessage(message) {
78
310
  if (!createdAt) {
79
311
  return null;
80
312
  }
313
+ const explicitUnread = readBoolean(message.unread, message.isUnread, message.is_unread);
314
+ const explicitRead = readBoolean(message.read, message.isRead, message.is_read);
81
315
  return {
82
316
  id: message.id,
83
- text: message.text,
317
+ text: readText(message.text),
84
318
  createdAt,
319
+ agentName: readString(message.agentName, message.agent_name),
320
+ unread: explicitUnread ?? (explicitRead === undefined ? undefined : !explicitRead),
85
321
  };
86
322
  }
87
323
  function normalizeUnreadDm(dm) {
88
324
  const conversationId = readString(dm.conversationId, dm.conversation_id);
89
- const unreadCount = readNumber(dm.unreadCount, dm.unread_count);
90
- if (!conversationId || unreadCount === undefined) {
325
+ const from = readString(dm.from);
326
+ const unreadCount = normalizeUnreadCount(readNumber(dm.unreadCount, dm.unread_count));
327
+ if (!conversationId || !from) {
91
328
  return null;
92
329
  }
93
330
  return {
94
331
  conversationId,
95
- from: dm.from,
332
+ from,
96
333
  unreadCount,
97
334
  lastMessage: normalizeLastMessage(dm.lastMessage ?? dm.last_message),
335
+ // Current Relaycast inbox summaries expose last_message without sender
336
+ // metadata. Keep messages[] forward-compatible, but production previews
337
+ // are expected to use the explicit DM body fetch path today.
338
+ messages: (Array.isArray(dm.messages) ? dm.messages : []).map(normalizeLastMessage).filter(isPresent),
98
339
  };
99
340
  }
100
341
  function normalizeRecentReaction(reaction) {
@@ -129,6 +370,86 @@ function normalizeInbox(inbox) {
129
370
  .filter(isPresent),
130
371
  };
131
372
  }
373
+ async function getUnreadDmDisplayMessages(relaycast, dm) {
374
+ const embeddedMessages = dm.messages.length > 0 ? dm.messages : dm.lastMessage ? [dm.lastMessage] : [];
375
+ const inboundEmbedded = sortUnreadDmMessagesMostRecentFirst(embeddedMessages.filter((message) => isInboundUnreadDmMessage(message, dm.from)));
376
+ const unreadEmbedded = selectUnreadCandidates(inboundEmbedded, dm.unreadCount);
377
+ const targetVisibleCount = Math.min(3, dm.unreadCount);
378
+ if (unreadEmbedded.length >= targetVisibleCount || dm.unreadCount === 0) {
379
+ return unreadEmbedded;
380
+ }
381
+ try {
382
+ const fetchedMessages = (await relaycast.dms.messages(dm.conversationId, {
383
+ limit: INBOX_DM_PREVIEW_FETCH_LIMIT,
384
+ }))
385
+ .map((message) => normalizeDmMessage(message, {
386
+ createdAt: FALLBACK_DM_CREATED_AT,
387
+ }))
388
+ .filter(isPresent)
389
+ .filter((message) => message.agentName === dm.from);
390
+ const candidateMessages = selectUnreadCandidates(fetchedMessages, dm.unreadCount);
391
+ const mergedMessages = new Map();
392
+ for (const message of [...unreadEmbedded, ...candidateMessages]) {
393
+ mergedMessages.set(message.id, message);
394
+ }
395
+ return sortUnreadDmMessagesMostRecentFirst([...mergedMessages.values()]);
396
+ }
397
+ catch {
398
+ if (unreadEmbedded.length === 0 && dm.unreadCount > 0) {
399
+ return [
400
+ {
401
+ id: `diagnostic:${dm.conversationId}`,
402
+ text: '',
403
+ createdAt: FALLBACK_DM_CREATED_AT,
404
+ diagnostic: `(could not load message bodies — run \`agent-relay replies ${renderShellQuotedForTerminal(dm.from)} --unread\`)`,
405
+ },
406
+ ];
407
+ }
408
+ return unreadEmbedded;
409
+ }
410
+ }
411
+ async function mapWithConcurrency(items, concurrency, mapper) {
412
+ const step = Math.max(1, Math.floor(concurrency));
413
+ const results = new Array(items.length);
414
+ for (let index = 0; index < items.length; index += step) {
415
+ const batch = items.slice(index, index + step);
416
+ const batchResults = await Promise.all(batch.map((item, batchIndex) => mapper(item, index + batchIndex)));
417
+ for (const [batchIndex, result] of batchResults.entries()) {
418
+ results[index + batchIndex] = result;
419
+ }
420
+ }
421
+ return results;
422
+ }
423
+ async function markDmMessagesRead(relaycast, conversationId, messages) {
424
+ const ids = messages.map((message) => message.id);
425
+ if (ids.length === 0) {
426
+ return;
427
+ }
428
+ if (typeof relaycast.markRead === 'function') {
429
+ await mapWithConcurrency(ids, MARK_READ_CONCURRENCY, async (id) => relaycast.markRead(id));
430
+ return;
431
+ }
432
+ if (typeof relaycast.dms.markMessagesRead === 'function') {
433
+ await relaycast.dms.markMessagesRead(conversationId, ids);
434
+ return;
435
+ }
436
+ if (typeof relaycast.dms.markRead === 'function') {
437
+ await relaycast.dms.markRead(conversationId, ids);
438
+ return;
439
+ }
440
+ if (typeof relaycast.markMessagesRead === 'function') {
441
+ await relaycast.markMessagesRead(conversationId, ids);
442
+ }
443
+ }
444
+ function getConversationRecency(conversation) {
445
+ const lastMessage = conversation.lastMessage ?? conversation.last_message;
446
+ return (Date.parse(readString(lastMessage?.createdAt, lastMessage?.created_at) ?? '') ||
447
+ Date.parse(readString(conversation.createdAt, conversation.created_at) ?? '') ||
448
+ 0);
449
+ }
450
+ function sortConversationsMostRecentFirst(conversations) {
451
+ return [...conversations].sort((a, b) => getConversationRecency(b) - getConversationRecency(a));
452
+ }
132
453
  async function createDefaultClient(cwd) {
133
454
  // Connect to an existing broker if one is running, otherwise spawn
134
455
  try {
@@ -177,10 +498,14 @@ async function createDefaultRelaycastClient(options) {
177
498
  type: 'agent',
178
499
  });
179
500
  const agentClient = relaycast.as(registration.token);
501
+ return wrapRelaycastAgentClient(agentClient);
502
+ }
503
+ export function wrapRelaycastAgentClient(agentClient) {
180
504
  // AgentClient already has dm(agent, text) — preserve the original reference before casting.
181
505
  // post() bridges to AgentClient.send() which has a different name.
182
506
  const originalDm = agentClient.dm.bind(agentClient);
183
507
  const originalSend = agentClient.send.bind(agentClient);
508
+ const originalDisconnect = agentClient.disconnect.bind(agentClient);
184
509
  const client = agentClient;
185
510
  client.dm = async (to, text) => {
186
511
  await originalDm(to, text);
@@ -188,6 +513,9 @@ async function createDefaultRelaycastClient(options) {
188
513
  client.post = async (channel, text) => {
189
514
  await originalSend(channel, text);
190
515
  };
516
+ client.disconnect = async () => {
517
+ await originalDisconnect();
518
+ };
191
519
  return client;
192
520
  }
193
521
  function withDefaults(overrides = {}) {
@@ -208,16 +536,17 @@ export function registerMessagingCommands(program, overrides = {}) {
208
536
  .description('Send a message to an agent')
209
537
  .argument('<agent>', 'Target agent name (or * for broadcast, #channel for channel)')
210
538
  .argument('<message>', 'Message to send')
211
- .option('--from <name>', 'Sender name (registered identity in relaycast, defaults to "relay")')
539
+ .option('--from <name>', 'Sender name (registered identity in relaycast). Default: $AGENT_RELAY_ORCHESTRATOR_NAME or "orchestrator"; use this identity with `agent-relay replies <worker>`.')
212
540
  .option('--thread <id>', 'Thread identifier')
213
541
  .action(async (agent, message, options) => {
214
- const senderName = options.from?.trim() || 'relay';
542
+ const senderName = options.from?.trim() || getDefaultOrchestratorName();
215
543
  const isChannel = agent.startsWith('#');
216
544
  // Primary path: send via relaycast SDK so messages are stored and queryable
217
545
  // Skip relaycast path when --thread is used since the relaycast SDK does not support threading
218
546
  if (!options.thread) {
547
+ let relaycastClient;
219
548
  try {
220
- const relaycastClient = await deps.createRelaycastClient({
549
+ relaycastClient = await deps.createRelaycastClient({
221
550
  agentName: senderName,
222
551
  cwd: deps.getProjectRoot(),
223
552
  });
@@ -227,12 +556,15 @@ export function registerMessagingCommands(program, overrides = {}) {
227
556
  else {
228
557
  await relaycastClient.dm(agent, message);
229
558
  }
230
- deps.log(`Message sent to ${agent}`);
559
+ deps.log(`Message sent to ${sanitizeForTerminalLine(agent)}`);
231
560
  return;
232
561
  }
233
562
  catch {
234
563
  // Fall through to broker path
235
564
  }
565
+ finally {
566
+ await disconnectRelaycastClient(relaycastClient);
567
+ }
236
568
  }
237
569
  // Fallback: broker path (for environments without relaycast API key)
238
570
  let brokerClient;
@@ -240,7 +572,7 @@ export function registerMessagingCommands(program, overrides = {}) {
240
572
  brokerClient = await deps.createClient(deps.getProjectRoot());
241
573
  }
242
574
  catch (err) {
243
- deps.error(`Failed to connect to broker: ${err?.message || String(err)}`);
575
+ deps.error(`Failed to connect to broker: ${formatErrorDetail(err)}`);
244
576
  deps.error('Start the broker with `agent-relay up` and try again.');
245
577
  deps.exit(1);
246
578
  return;
@@ -249,13 +581,13 @@ export function registerMessagingCommands(program, overrides = {}) {
249
581
  await brokerClient.sendMessage({
250
582
  to: agent,
251
583
  text: message,
252
- from: options.from?.trim() ? options.from.trim() : undefined,
584
+ from: senderName,
253
585
  threadId: options.thread,
254
586
  });
255
- deps.log(`Message sent to ${agent}`);
587
+ deps.log(`Message sent to ${sanitizeForTerminalLine(agent)}`);
256
588
  }
257
589
  catch (err) {
258
- deps.error(`Failed to send message: ${err?.message || String(err)}`);
590
+ deps.error(`Failed to send message: ${formatErrorDetail(err)}`);
259
591
  deps.exit(1);
260
592
  }
261
593
  finally {
@@ -276,7 +608,7 @@ export function registerMessagingCommands(program, overrides = {}) {
276
608
  });
277
609
  }
278
610
  catch (err) {
279
- deps.error(`Failed to initialize relaycast client: ${err?.message || String(err)}`);
611
+ deps.error(`Failed to initialize relaycast client: ${formatErrorDetail(err)}`);
280
612
  deps.exit(1);
281
613
  return;
282
614
  }
@@ -286,17 +618,20 @@ export function registerMessagingCommands(program, overrides = {}) {
286
618
  if (!normalizedMessage) {
287
619
  throw new Error(`message ${messageId} is missing sender or timestamp metadata`);
288
620
  }
289
- deps.log(`From: ${normalizedMessage.agentName}`);
621
+ deps.log(`From: ${sanitizeForTerminalLine(normalizedMessage.agentName)}`);
290
622
  deps.log('To: #channel');
291
- deps.log(`Time: ${normalizedMessage.createdAt}`);
623
+ deps.log(`Time: ${sanitizeForTerminalLine(normalizedMessage.createdAt)}`);
292
624
  deps.log('---');
293
625
  deps.log(normalizedMessage.text);
294
626
  }
295
627
  catch (err) {
296
- deps.error(`Failed to read message ${messageId}: ${err?.message || String(err)}`);
628
+ deps.error(`Failed to read message ${sanitizeForTerminalLine(messageId)}: ${formatErrorDetail(err)}`);
297
629
  deps.error('Ensure the broker is running (`agent-relay up`) and try again.');
298
630
  deps.exit(1);
299
631
  }
632
+ finally {
633
+ await disconnectRelaycastClient(relaycast);
634
+ }
300
635
  });
301
636
  program
302
637
  .command('history')
@@ -305,67 +640,124 @@ export function registerMessagingCommands(program, overrides = {}) {
305
640
  .option('-f, --from <agent>', 'Filter by sender')
306
641
  .option('-t, --to <agent>', 'Filter by recipient')
307
642
  .option('--thread <id>', 'Filter by thread ID')
308
- .option('--since <time>', 'Since time (e.g., "1h", "30m", "2024-01-01")')
643
+ .option('--since <time|id>', 'Time ("1h", "30m", "2024-01-01") or a message-id cursor (only messages after that id)')
309
644
  .option('--json', 'Output as JSON')
310
645
  .option('--storage <type>', 'Storage type override (jsonl, sqlite, memory)')
311
646
  .action(async (options) => {
312
- const limit = Number.parseInt(options.limit ?? '50', 10) || 50;
313
- const sinceTs = parseSince(options.since);
647
+ let limit;
648
+ let since;
649
+ try {
650
+ limit = parseMessageLimit(options.limit);
651
+ since = resolveSince(options.since);
652
+ }
653
+ catch (err) {
654
+ deps.error(formatErrorDetail(err));
655
+ deps.exit(1);
656
+ return;
657
+ }
658
+ const sinceTs = since.kind === 'time' ? since.ts : undefined;
314
659
  if (options.from && !options.to) {
660
+ if (!isAuthorizedReadIdentity(options.from)) {
661
+ reportUnauthorizedReadIdentity(deps, options.from);
662
+ return;
663
+ }
315
664
  // Cross-context sender history: channel messages + DMs sent by this agent
316
665
  const channelItems = [];
317
666
  const dmItems = [];
318
- // Part 1: channel messages from this agent
667
+ const sourceErrors = [];
668
+ let relaycastClient;
319
669
  try {
320
- const channelClient = await deps.createRelaycastClient({
321
- agentName: '__cli_history__',
670
+ relaycastClient = await deps.createRelaycastClient({
671
+ agentName: options.from,
322
672
  cwd: deps.getProjectRoot(),
323
673
  });
324
- const raw = (await channelClient.messages('general', { limit: Math.max(limit * 2, 100) }))
325
- .map(normalizeMessage)
326
- .filter(isPresent)
327
- .filter((msg) => msg.agentName === options.from)
328
- .filter((msg) => !sinceTs || Date.parse(msg.createdAt) >= sinceTs)
329
- .slice(0, limit);
330
- for (const msg of raw) {
331
- channelItems.push({ ts: msg.createdAt, to: '#general', text: msg.text, kind: 'channel' });
674
+ }
675
+ catch (err) {
676
+ const detail = formatErrorDetail(err);
677
+ sourceErrors.push(`channel: ${detail}`);
678
+ sourceErrors.push(`dm: ${detail}`);
679
+ }
680
+ // Part 1: channel messages from this agent
681
+ try {
682
+ if (!relaycastClient) {
683
+ // Initialization failure already contributed both source errors.
684
+ }
685
+ else {
686
+ const raw = (await relaycastClient.messages('general', { limit: Math.max(limit * 2, 100) }))
687
+ .map(normalizeMessage)
688
+ .filter(isPresent)
689
+ .filter((msg) => msg.agentName === options.from)
690
+ .filter((msg) => !sinceTs || Date.parse(msg.createdAt) >= sinceTs)
691
+ // Relaycast feed order is not guaranteed: sort chronologically
692
+ // and keep the most recent `limit` (matches the channel branch).
693
+ // A bare slice(0, limit) here silently kept the OLDEST messages.
694
+ .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
695
+ .slice(-limit);
696
+ for (const msg of raw) {
697
+ channelItems.push({
698
+ id: msg.id,
699
+ ts: msg.createdAt,
700
+ to: '#general',
701
+ text: msg.text,
702
+ kind: 'channel',
703
+ });
704
+ }
332
705
  }
333
706
  }
334
- catch {
335
- // non-fatal — continue to DM section
707
+ catch (err) {
708
+ sourceErrors.push(`channel: ${formatErrorDetail(err)}`);
336
709
  }
337
710
  // Part 2: DM messages sent by this agent
338
711
  try {
339
- const dmClient = await deps.createRelaycastClient({
340
- agentName: options.from,
341
- cwd: deps.getProjectRoot(),
342
- });
343
- const conversations = await dmClient.dms.conversations();
344
- const perConvLimit = Math.max(Math.ceil(limit / Math.max(conversations.length, 1)), 10);
345
- for (const conv of conversations.slice(0, 10)) {
346
- const msgs = await dmClient.dms.messages(conv.id, { limit: perConvLimit });
347
- const recipient = conv.participants
348
- .filter((p) => (p.agentName || p.agent_name) !== options.from)
349
- .map((p) => p.agentName || p.agent_name)
350
- .join(', ') || '(self)';
351
- for (const m of msgs) {
352
- const sender = m.agentName || m.agent_name;
353
- if (sender !== options.from)
354
- continue;
355
- const ts = m.createdAt || m.created_at || '';
356
- if (sinceTs && Date.parse(ts) < sinceTs)
357
- continue;
358
- dmItems.push({ ts, to: recipient, text: m.text, kind: 'dm' });
712
+ if (!relaycastClient) {
713
+ // Initialization failure already contributed both source errors.
714
+ }
715
+ else {
716
+ const conversations = sortConversationsMostRecentFirst(await relaycastClient.dms.conversations()).slice(0, HISTORY_DM_CONVERSATION_SCAN_LIMIT);
717
+ const perConvLimit = Math.min(HISTORY_DM_PER_CONVERSATION_FETCH_LIMIT, MAX_DM_FETCH_LIMIT, Math.max(limit, 10));
718
+ const perConversationItems = await mapWithConcurrency(conversations, MARK_READ_CONCURRENCY, async (conv) => {
719
+ const msgs = await relaycastClient.dms.messages(conv.id, { limit: perConvLimit });
720
+ const recipient = conv.participants
721
+ .filter((p) => (p.agentName || p.agent_name) !== options.from)
722
+ .map((p) => p.agentName || p.agent_name)
723
+ .join(', ') || '(self)';
724
+ return msgs
725
+ .map((m) => {
726
+ const sender = m.agentName || m.agent_name;
727
+ if (sender !== options.from)
728
+ return null;
729
+ const ts = m.createdAt || m.created_at || '';
730
+ if (sinceTs && Date.parse(ts) < sinceTs)
731
+ return null;
732
+ return { id: m.id, ts, to: recipient, text: readText(m.text), kind: 'dm' };
733
+ })
734
+ .filter(isPresent);
735
+ });
736
+ for (const items of perConversationItems) {
737
+ dmItems.push(...items);
359
738
  }
360
739
  }
361
740
  }
362
- catch {
363
- // non-fatal — continue with channel results only
741
+ catch (err) {
742
+ sourceErrors.push(`dm: ${formatErrorDetail(err)}`);
743
+ }
744
+ finally {
745
+ await disconnectRelaycastClient(relaycastClient);
746
+ }
747
+ const orderedItems = [...channelItems, ...dmItems].sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
748
+ const allItems = (since.kind === 'cursor' ? sliceAfterCursor(orderedItems, since.id) : orderedItems).slice(-limit);
749
+ if (!allItems.length && sourceErrors.length >= 2) {
750
+ deps.error(`Failed to fetch history sources: ${sourceErrors.join('; ')}`);
751
+ deps.exit(1);
752
+ return;
753
+ }
754
+ if (sourceErrors.length > 0) {
755
+ deps.error(`Warning: partial history results; ${sourceErrors.join('; ')}`);
364
756
  }
365
- const allItems = [...channelItems, ...dmItems].sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
366
757
  if (options.json) {
367
758
  deps.log(JSON.stringify(allItems.map((item) => ({
368
759
  from: options.from,
760
+ id: item.id,
369
761
  to: item.to,
370
762
  text: item.text,
371
763
  createdAt: item.ts,
@@ -378,87 +770,94 @@ export function registerMessagingCommands(program, overrides = {}) {
378
770
  return;
379
771
  }
380
772
  allItems.forEach((item) => {
381
- const body = item.text.length > 200 ? item.text.slice(0, 197) + '...' : item.text;
382
- if (item.kind === 'dm') {
383
- deps.log('[' + item.ts + '] ' + options.from + ' -> ' + item.to + ' (DM): ' + body);
773
+ const suffix = item.kind === 'dm' ? ' (DM)' : '';
774
+ const header = `[${sanitizeForTerminalLine(item.ts)}] ${sanitizeForTerminalLine(options.from ?? '')} -> ${sanitizeForTerminalLine(item.to)}${suffix}`;
775
+ // No truncation: substantive payloads (diffs, grep counts,
776
+ // GO/NO-GO reasoning) must be readable in full. Multi-line
777
+ // messages print under an indented header.
778
+ const lines = item.text.split(/\r?\n/).map(sanitizeForTerminal);
779
+ if (lines.length === 1) {
780
+ deps.log(`${header}: ${lines[0]}`);
384
781
  }
385
782
  else {
386
- deps.log('[' + item.ts + '] ' + options.from + ' -> ' + item.to + ': ' + body);
783
+ deps.log(`${header}:`);
784
+ for (const line of lines) {
785
+ deps.log(` ${line}`);
786
+ }
387
787
  }
388
788
  });
389
789
  return;
390
790
  }
391
791
  if (options.to && !options.to.startsWith('#')) {
392
- // DM history mode: register as the target agent and show their conversations
792
+ const toName = options.to;
793
+ const readerName = getDefaultOrchestratorName();
393
794
  let dmClient;
394
795
  try {
395
796
  dmClient = await deps.createRelaycastClient({
396
- agentName: options.to,
797
+ agentName: readerName,
397
798
  cwd: deps.getProjectRoot(),
398
799
  });
399
800
  }
400
801
  catch (err) {
401
- deps.error(`Failed to initialize relaycast client: ${err?.message || String(err)}`);
802
+ deps.error(`Failed to initialize relaycast client: ${formatErrorDetail(err)}`);
402
803
  deps.exit(1);
403
804
  return;
404
805
  }
405
806
  try {
406
807
  const conversations = await dmClient.dms.conversations();
407
- if (options.from) {
408
- // Show messages in the specific conversation with --from agent
409
- const conv = conversations.find((c) => c.participants.some((p) => (p.agentName || p.agent_name) === options.from));
410
- if (!conv) {
411
- deps.log(`No DM conversation found between ${options.to} and ${options.from}.`);
412
- return;
413
- }
414
- const messages = await dmClient.dms.messages(conv.id, { limit });
415
- if (options.json) {
416
- deps.log(JSON.stringify(messages.map((m) => ({
417
- id: m.id,
418
- from: m.agentName || m.agent_name || 'unknown',
419
- text: m.text,
420
- createdAt: m.createdAt || m.created_at,
421
- })), null, 2));
422
- return;
423
- }
424
- if (!messages.length) {
425
- deps.log('No messages found.');
426
- return;
427
- }
428
- messages.forEach((m) => {
429
- const sender = m.agentName || m.agent_name || 'unknown';
430
- const ts = m.createdAt || m.created_at || '';
431
- const body = m.text.length > 200 ? `${m.text.slice(0, 197)}...` : m.text;
432
- deps.log(`[${ts}] ${sender}: ${body}`);
433
- });
808
+ const conversation = findDirectDmConversation(conversations, readerName, toName);
809
+ if (!conversation) {
810
+ deps.log(`No DM conversation found between ${sanitizeForTerminalLine(readerName)} and ${sanitizeForTerminalLine(toName)}.`);
811
+ return;
434
812
  }
435
- else {
436
- // Show all conversations summary
437
- if (options.json) {
438
- deps.log(JSON.stringify(conversations, null, 2));
439
- return;
440
- }
441
- if (!conversations.length) {
442
- deps.log(`No DM conversations found for ${options.to}.`);
443
- return;
444
- }
445
- deps.log(`DM conversations for ${options.to}:`);
446
- conversations.forEach((conv) => {
447
- const others = conv.participants
448
- .filter((p) => (p.agentName || p.agent_name) !== options.to)
449
- .map((p) => p.agentName || p.agent_name)
450
- .join(', ');
451
- const lastText = (conv.lastMessage || conv.last_message)?.text ?? '(no messages)';
452
- const preview = lastText.length > 60 ? `${lastText.slice(0, 57)}...` : lastText;
453
- const unread = conv.unreadCount ?? conv.unread_count ?? 0;
454
- deps.log(` ${others || '(self)'}: "${preview}" [${unread} unread]`);
813
+ const collected = [];
814
+ const rawFetchLimit = options.from || sinceTs || since.kind === 'cursor' ? getFilteredDmFetchLimit(limit) : limit;
815
+ const dmMessages = await dmClient.dms.messages(conversation.id, { limit: rawFetchLimit });
816
+ for (const message of dmMessages) {
817
+ const normalized = normalizeDmMessage(message, {
818
+ agentName: toName,
819
+ createdAt: FALLBACK_DM_CREATED_AT,
820
+ });
821
+ if (!normalized)
822
+ continue;
823
+ if (options.from && normalized.agentName !== options.from)
824
+ continue;
825
+ if (sinceTs && Date.parse(normalized.createdAt) < sinceTs)
826
+ continue;
827
+ collected.push({
828
+ ...normalized,
829
+ to: normalized.agentName === readerName ? toName : readerName,
455
830
  });
456
831
  }
832
+ const ordered = sortDmMessagesChronologically(collected);
833
+ // `--since <id>`: only messages strictly after that id (no replay).
834
+ const messages = (since.kind === 'cursor' ? sliceAfterCursor(ordered, since.id) : ordered).slice(-limit);
835
+ if (options.json) {
836
+ deps.log(JSON.stringify(messages.map((message) => ({
837
+ id: message.id,
838
+ from: message.agentName,
839
+ to: message.to,
840
+ text: message.text,
841
+ createdAt: message.createdAt,
842
+ direction: message.agentName === readerName ? 'outbound' : 'inbound',
843
+ })), null, 2));
844
+ return;
845
+ }
846
+ if (!messages.length) {
847
+ deps.log('No messages found.');
848
+ return;
849
+ }
850
+ for (const message of messages) {
851
+ renderTranscriptMessage(deps.log, message);
852
+ }
457
853
  }
458
854
  catch (err) {
459
- deps.error(`Failed to fetch DM history: ${err?.message || String(err)}`);
855
+ deps.error(`Failed to fetch DM history: ${formatErrorDetail(err)}`);
460
856
  deps.exit(1);
461
857
  }
858
+ finally {
859
+ await disconnectRelaycastClient(dmClient);
860
+ }
462
861
  return;
463
862
  }
464
863
  let relaycast;
@@ -469,7 +868,7 @@ export function registerMessagingCommands(program, overrides = {}) {
469
868
  });
470
869
  }
471
870
  catch (err) {
472
- deps.error(`Failed to initialize relaycast client: ${err?.message || String(err)}`);
871
+ deps.error(`Failed to initialize relaycast client: ${formatErrorDetail(err)}`);
473
872
  deps.exit(1);
474
873
  return;
475
874
  }
@@ -488,7 +887,17 @@ export function registerMessagingCommands(program, overrides = {}) {
488
887
  return false;
489
888
  return true;
490
889
  });
491
- filteredMessages = filteredMessages.slice(0, limit);
890
+ // Order chronologically (oldest first, newest at the bottom) so a
891
+ // reader reconstructs the conversation top-to-bottom like a
892
+ // transcript, then keep the most recent `limit` messages. The
893
+ // relaycast feed order is not guaranteed, so an explicit sort is
894
+ // required to stop messages interleaving out of order.
895
+ filteredMessages = filteredMessages.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
896
+ // `--since <id>`: only messages strictly after that id (no replay).
897
+ if (since.kind === 'cursor') {
898
+ filteredMessages = sliceAfterCursor(filteredMessages, since.id);
899
+ }
900
+ filteredMessages = filteredMessages.slice(-limit);
492
901
  if (options.json) {
493
902
  const payload = filteredMessages.map((msg) => ({
494
903
  id: msg.id,
@@ -508,16 +917,31 @@ export function registerMessagingCommands(program, overrides = {}) {
508
917
  deps.log('No messages found.');
509
918
  return;
510
919
  }
920
+ // No truncation: channel evidence (literal diffs, grep counts,
921
+ // GO/NO-GO reasoning) must be fully readable. Multi-line messages
922
+ // print under an indented header.
511
923
  filteredMessages.forEach((msg) => {
512
- const body = msg.text.length > 200 ? `${msg.text.slice(0, 197)}...` : msg.text;
513
- deps.log(`[${msg.createdAt}] ${msg.agentName} -> #${channel}: ${body}`);
924
+ const header = `[${sanitizeForTerminalLine(msg.createdAt)}] ${sanitizeForTerminalLine(msg.agentName)} -> #${sanitizeForTerminalLine(channel)}`;
925
+ const lines = msg.text.split(/\r?\n/).map(sanitizeForTerminal);
926
+ if (lines.length === 1) {
927
+ deps.log(`${header}: ${lines[0]}`);
928
+ }
929
+ else {
930
+ deps.log(`${header}:`);
931
+ for (const line of lines) {
932
+ deps.log(` ${line}`);
933
+ }
934
+ }
514
935
  });
515
936
  }
516
937
  catch (err) {
517
- deps.error(`Failed to fetch history: ${err?.message || String(err)}`);
938
+ deps.error(`Failed to fetch history: ${formatErrorDetail(err)}`);
518
939
  deps.error('Ensure the broker is running (`agent-relay up`) and try again.');
519
940
  deps.exit(1);
520
941
  }
942
+ finally {
943
+ await disconnectRelaycastClient(relaycast);
944
+ }
521
945
  });
522
946
  program
523
947
  .command('inbox')
@@ -525,21 +949,29 @@ export function registerMessagingCommands(program, overrides = {}) {
525
949
  .option('--agent <name>', 'Agent whose inbox to check (defaults to cli user)')
526
950
  .option('--json', 'Output as JSON')
527
951
  .action(async (options) => {
952
+ const requestedReaderName = options.agent?.trim();
953
+ if (requestedReaderName && !isAuthorizedReadIdentity(requestedReaderName)) {
954
+ reportUnauthorizedReadIdentity(deps, requestedReaderName);
955
+ return;
956
+ }
957
+ const readerName = requestedReaderName || '__cli_inbox__';
528
958
  let relaycast;
529
959
  try {
530
960
  relaycast = await deps.createRelaycastClient({
531
- agentName: options.agent?.trim() || '__cli_inbox__',
961
+ agentName: readerName,
532
962
  cwd: deps.getProjectRoot(),
533
963
  });
534
964
  }
535
965
  catch (err) {
536
- deps.error(`Failed to initialize relaycast client: ${err?.message || String(err)}`);
966
+ deps.error(`Failed to initialize relaycast client: ${formatErrorDetail(err)}`);
537
967
  deps.exit(1);
538
968
  return;
539
969
  }
540
970
  try {
541
971
  const inbox = normalizeInbox(await relaycast.inbox());
542
972
  if (options.json) {
973
+ const unreadDmsForFetch = inbox.unreadDms.slice(0, INBOX_UNREAD_DM_FETCH_CONVERSATION_LIMIT);
974
+ const unreadDmDisplays = await mapWithConcurrency(unreadDmsForFetch, MARK_READ_CONCURRENCY, async (dm) => getUnreadDmDisplayMessages(relaycast, dm));
543
975
  const payload = {
544
976
  unread_channels: inbox.unreadChannels.map((item) => ({
545
977
  channel_name: item.channelName,
@@ -552,18 +984,29 @@ export function registerMessagingCommands(program, overrides = {}) {
552
984
  text: mention.text,
553
985
  created_at: mention.createdAt,
554
986
  })),
555
- unread_dms: inbox.unreadDms.map((dm) => ({
556
- conversation_id: dm.conversationId,
557
- from: dm.from,
558
- unread_count: dm.unreadCount,
559
- last_message: dm.lastMessage
560
- ? {
561
- id: dm.lastMessage.id,
562
- text: dm.lastMessage.text,
563
- created_at: dm.lastMessage.createdAt,
564
- }
565
- : null,
566
- })),
987
+ unread_dms: inbox.unreadDms.map((dm, index) => {
988
+ const selectedMessage = unreadDmDisplays[index]?.[0];
989
+ const lastMessageError = selectedMessage?.diagnostic;
990
+ const selectedDisplayMessage = lastMessageError ? undefined : selectedMessage;
991
+ const lastMessage = selectedDisplayMessage ?? dm.lastMessage;
992
+ return {
993
+ conversation_id: dm.conversationId,
994
+ from: dm.from,
995
+ unread_count: dm.unreadCount,
996
+ last_message: lastMessage
997
+ ? {
998
+ id: lastMessage.id,
999
+ text: lastMessage.text,
1000
+ created_at: lastMessage.createdAt,
1001
+ direction: selectedDisplayMessage?.direction ??
1002
+ (selectedDisplayMessage
1003
+ ? 'inbound'
1004
+ : getLastMessageDirection(lastMessage, dm.from, readerName)),
1005
+ }
1006
+ : null,
1007
+ ...(lastMessageError ? { last_message_error: lastMessageError } : {}),
1008
+ };
1009
+ }),
567
1010
  recent_reactions: inbox.recentReactions.map((reaction) => ({
568
1011
  message_id: reaction.messageId,
569
1012
  channel_name: reaction.channelName,
@@ -586,7 +1029,7 @@ export function registerMessagingCommands(program, overrides = {}) {
586
1029
  if (inbox.unreadChannels.length > 0) {
587
1030
  deps.log('Unread Channels:');
588
1031
  for (const item of inbox.unreadChannels) {
589
- deps.log(` #${item.channelName}: ${item.unreadCount}`);
1032
+ deps.log(` #${sanitizeForTerminalLine(item.channelName)}: ${item.unreadCount}`);
590
1033
  }
591
1034
  deps.log('');
592
1035
  }
@@ -594,28 +1037,149 @@ export function registerMessagingCommands(program, overrides = {}) {
594
1037
  deps.log('Mentions:');
595
1038
  for (const mention of inbox.mentions) {
596
1039
  const preview = mention.text.length > 120 ? `${mention.text.slice(0, 117)}...` : mention.text;
597
- deps.log(` [${mention.createdAt}] #${mention.channelName} @${mention.agentName}: ${preview}`);
1040
+ deps.log(` [${sanitizeForTerminalLine(mention.createdAt)}] #${sanitizeForTerminalLine(mention.channelName)} @${sanitizeForTerminalLine(mention.agentName)}: ${sanitizeForTerminalLine(preview)}`);
598
1041
  }
599
1042
  deps.log('');
600
1043
  }
601
1044
  if (inbox.unreadDms.length > 0) {
602
1045
  deps.log('Unread DMs:');
603
- for (const dm of inbox.unreadDms) {
604
- deps.log(` ${dm.from}: ${dm.unreadCount}`);
1046
+ const unreadDmsForFetch = inbox.unreadDms.slice(0, INBOX_UNREAD_DM_FETCH_CONVERSATION_LIMIT);
1047
+ const unreadDmDisplays = await mapWithConcurrency(unreadDmsForFetch, MARK_READ_CONCURRENCY, async (dm) => getUnreadDmDisplayMessages(relaycast, dm));
1048
+ for (const [index, dm] of inbox.unreadDms.entries()) {
1049
+ deps.log(` ${sanitizeForTerminalLine(dm.from)} → ${sanitizeForTerminalLine(readerName)} (${dm.unreadCount} unread):`);
1050
+ const displayMessages = unreadDmDisplays[index] ?? [];
1051
+ const visibleMessages = displayMessages.slice(0, 3);
1052
+ for (const message of visibleMessages) {
1053
+ renderUnreadDmMessage(deps.log, message, dm.from);
1054
+ }
1055
+ const remaining = Math.max(dm.unreadCount, displayMessages.length) - visibleMessages.length;
1056
+ if (remaining > 0) {
1057
+ deps.log(` … (${remaining} more — run \`agent-relay replies ${renderShellQuotedForTerminal(dm.from)} --unread\` to see all)`);
1058
+ }
605
1059
  }
606
1060
  deps.log('');
607
1061
  }
608
1062
  if (inbox.recentReactions.length > 0) {
609
1063
  deps.log('Recent Reactions:');
610
1064
  for (const reaction of inbox.recentReactions) {
611
- deps.log(` [${reaction.createdAt}] #${reaction.channelName} ${reaction.emoji} by @${reaction.agentName}`);
1065
+ deps.log(` [${sanitizeForTerminalLine(reaction.createdAt)}] #${sanitizeForTerminalLine(reaction.channelName)} ${sanitizeForTerminalLine(reaction.emoji)} by @${sanitizeForTerminalLine(reaction.agentName)}`);
612
1066
  }
613
1067
  }
614
1068
  }
615
1069
  catch (err) {
616
- deps.error(`Failed to fetch inbox: ${err?.message || String(err)}`);
1070
+ deps.error(`Failed to fetch inbox: ${formatErrorDetail(err)}`);
617
1071
  deps.exit(1);
618
1072
  }
1073
+ finally {
1074
+ await disconnectRelaycastClient(relaycast);
1075
+ }
1076
+ });
1077
+ program
1078
+ .command('replies')
1079
+ .description('Show DM replies received from an agent')
1080
+ .argument('<agent>', 'Agent whose replies to show')
1081
+ .option('-n, --limit <count>', 'Number of messages to show', '50')
1082
+ .option('--since <time|id>', 'Time ("5m", "1h", ISO-8601) or a message-id cursor (only messages after that id; no replay)')
1083
+ .option('--unread', 'Only unread messages')
1084
+ .option('--mark-read', 'Mark displayed unread replies as read')
1085
+ .option('--as <name>', 'Read as this orchestrator identity')
1086
+ .option('--json', 'Output as JSON')
1087
+ .option('--full', 'Disable truncation; text is always printed in full')
1088
+ .action(async (agent, options) => {
1089
+ let limit;
1090
+ let since;
1091
+ try {
1092
+ limit = parseMessageLimit(options.limit);
1093
+ since = resolveSince(options.since);
1094
+ }
1095
+ catch (err) {
1096
+ deps.error(formatErrorDetail(err));
1097
+ deps.exit(1);
1098
+ return;
1099
+ }
1100
+ const sinceTs = since.kind === 'time' ? since.ts : undefined;
1101
+ const requestedReaderName = options.as?.trim();
1102
+ if (requestedReaderName && !isAuthorizedReadIdentity(requestedReaderName)) {
1103
+ reportUnauthorizedReadIdentity(deps, requestedReaderName);
1104
+ return;
1105
+ }
1106
+ const readerName = requestedReaderName || getDefaultOrchestratorName();
1107
+ let relaycast;
1108
+ try {
1109
+ relaycast = await deps.createRelaycastClient({
1110
+ agentName: readerName,
1111
+ cwd: deps.getProjectRoot(),
1112
+ });
1113
+ }
1114
+ catch (err) {
1115
+ deps.error(`Failed to initialize relaycast client: ${formatErrorDetail(err)}`);
1116
+ deps.exit(1);
1117
+ return;
1118
+ }
1119
+ try {
1120
+ const conversations = await relaycast.dms.conversations();
1121
+ const conversation = findDirectDmConversation(conversations, readerName, agent);
1122
+ if (!conversation) {
1123
+ deps.log(`No DM conversation with ${sanitizeForTerminalLine(agent)}.`);
1124
+ return;
1125
+ }
1126
+ const rawUnreadCount = readNumber(conversation.unreadCount, conversation.unread_count);
1127
+ const unreadCount = normalizeUnreadCount(rawUnreadCount);
1128
+ const messages = sortDmMessagesChronologically((await relaycast.dms.messages(conversation.id, {
1129
+ limit: getFilteredDmFetchLimit(limit, unreadCount),
1130
+ }))
1131
+ .map((message) => normalizeDmMessage(message, {
1132
+ agentName: agent,
1133
+ createdAt: FALLBACK_DM_CREATED_AT,
1134
+ }))
1135
+ .filter(isPresent)
1136
+ .filter((message) => message.agentName === agent)
1137
+ .filter((message) => !sinceTs || Date.parse(message.createdAt) >= sinceTs));
1138
+ // `--since <id>`: only messages strictly after that id, so a
1139
+ // polling caller never re-receives what it already saw.
1140
+ const cursored = since.kind === 'cursor' ? sliceAfterCursor(messages, since.id) : messages;
1141
+ const hasPerMessageReadState = cursored.some((message) => message.unread !== undefined);
1142
+ const unreadStateUnknown = options.unread === true && rawUnreadCount === undefined && !hasPerMessageReadState;
1143
+ const filteredMessages = (options.unread && !unreadStateUnknown ? selectUnreadCandidates(cursored, unreadCount) : cursored).slice(-limit);
1144
+ if (options.json) {
1145
+ deps.log(JSON.stringify(filteredMessages.map((message) => ({
1146
+ id: message.id,
1147
+ from: message.agentName,
1148
+ to: readerName,
1149
+ text: message.text,
1150
+ createdAt: message.createdAt,
1151
+ direction: 'inbound',
1152
+ ...(unreadStateUnknown ? { unread_state: 'unknown' } : {}),
1153
+ ...(message.unread !== undefined ? { unread: message.unread } : {}),
1154
+ })), null, 2));
1155
+ }
1156
+ else if (!filteredMessages.length) {
1157
+ deps.log('No messages found.');
1158
+ }
1159
+ else {
1160
+ if (unreadStateUnknown) {
1161
+ deps.log('Unread state unavailable — showing recent inbound messages.');
1162
+ }
1163
+ for (const message of filteredMessages) {
1164
+ renderTranscriptMessage(deps.log, message);
1165
+ }
1166
+ }
1167
+ if (options.markRead && filteredMessages.length > 0) {
1168
+ try {
1169
+ await markDmMessagesRead(relaycast, conversation.id, filteredMessages);
1170
+ }
1171
+ catch (err) {
1172
+ deps.error(`Warning: failed to mark replies from ${sanitizeForTerminalLine(agent)} as read: ${formatErrorDetail(err)}`);
1173
+ }
1174
+ }
1175
+ }
1176
+ catch (err) {
1177
+ deps.error(`Failed to fetch replies for ${sanitizeForTerminalLine(agent)}: ${formatErrorDetail(err)}`);
1178
+ deps.exit(1);
1179
+ }
1180
+ finally {
1181
+ await disconnectRelaycastClient(relaycast);
1182
+ }
619
1183
  });
620
1184
  }
621
1185
  //# sourceMappingURL=messaging.js.map