letagents 0.12.22 → 0.12.23

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.
@@ -7,6 +7,8 @@ import { currentRoom, getCurrentSupervisedRoomAuthority } from "./room-state.js"
7
7
  import { hasSupervisedWorkerAuthority } from "./worker-bearer.js";
8
8
  import { currentWorkerCall } from "../../worker-call-context.js";
9
9
  export async function roomScopedApiCall(input) {
10
+ // A successful send does not prove that preceding messages were read.
11
+ const preserveCursor = input.preserve_session_cursor || (input.options?.method ?? "GET").toUpperCase() !== "GET";
10
12
  const supervised = hasSupervisedWorkerAuthority();
11
13
  const exactRoomAuthority = supervised ? getCurrentSupervisedRoomAuthority() : null;
12
14
  if (supervised && (!exactRoomAuthority || input.room_id !== exactRoomAuthority)) {
@@ -36,7 +38,7 @@ export async function roomScopedApiCall(input) {
36
38
  try {
37
39
  const result = await apiCall(input.room_path(apiRoomId), options);
38
40
  if (!supervised) {
39
- touchRoomSession(input.room_id, input.preserve_session_cursor ? undefined : getLastMessageId(result));
41
+ touchRoomSession(input.room_id, preserveCursor ? undefined : getLastMessageId(result));
40
42
  }
41
43
  return result;
42
44
  }
@@ -53,7 +55,7 @@ export async function roomScopedApiCall(input) {
53
55
  const result = await apiCall(input.project_path(input.project_id), options);
54
56
  if (input.room_id) {
55
57
  if (!supervised) {
56
- touchRoomSession(input.room_id, input.preserve_session_cursor ? undefined : getLastMessageId(result));
58
+ touchRoomSession(input.room_id, preserveCursor ? undefined : getLastMessageId(result));
57
59
  }
58
60
  }
59
61
  return result;
@@ -65,7 +65,7 @@ export function registerPostReasoningTool(server) {
65
65
  publisher_agent_session_id: agentSession?.session_id ?? null,
66
66
  });
67
67
  milestoneMessageId = milestoneMessage.id;
68
- touchCurrentRoom(milestoneMessageId);
68
+ touchCurrentRoom();
69
69
  }
70
70
  await syncRoomPresence(effectiveLocalRoomId, identity, {
71
71
  status: status ?? getRememberedRoomPresence(effectiveLocalRoomId, identity).status,
@@ -158,7 +158,7 @@ export function registerPostReasoningTool(server) {
158
158
  });
159
159
  milestoneMessageId =
160
160
  typeof milestoneMessage.id === "string" ? milestoneMessage.id : null;
161
- touchCurrentRoom(milestoneMessageId ?? undefined);
161
+ touchCurrentRoom();
162
162
  }
163
163
  if (status) {
164
164
  await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, {
@@ -90,7 +90,7 @@ async function sendMessageFromTool(input) {
90
90
  publisher_agent_key: agentSession?.agent_key ?? null,
91
91
  publisher_agent_session_id: agentSession?.session_id ?? null,
92
92
  });
93
- touchCurrentRoom(message.id);
93
+ touchCurrentRoom();
94
94
  return jsonToolResponse({
95
95
  ...message,
96
96
  agent_identity: toPublicAgentIdentity(identity),
@@ -112,7 +112,7 @@ async function sendMessageFromTool(input) {
112
112
  })),
113
113
  },
114
114
  });
115
- touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
115
+ touchCurrentRoom();
116
116
  await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity), agentSession);
117
117
  // The `replied` receipt transition is server-owned: message creation marks
118
118
  // the publisher's receipt on the reply target atomically with the reply
@@ -48,7 +48,7 @@ export function registerPostStatusTool(server) {
48
48
  publisher_agent_key: agentSession?.agent_key ?? null,
49
49
  publisher_agent_session_id: agentSession?.session_id ?? null,
50
50
  });
51
- touchCurrentRoom(message.id);
51
+ touchCurrentRoom();
52
52
  return jsonToolResponse({
53
53
  success: true,
54
54
  status_posted: status,
@@ -76,7 +76,7 @@ export function registerPostStatusTool(server) {
76
76
  }),
77
77
  },
78
78
  });
79
- touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
79
+ touchCurrentRoom();
80
80
  return jsonToolResponse({
81
81
  success: true,
82
82
  status_posted: status,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "letagents",
3
- "version": "0.12.22",
4
- "description": "Let Agents Chat MCP server for AI agent communication",
3
+ "version": "0.12.23",
4
+ "description": "Let Agents Chat \u2014 MCP server for AI agent communication",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/server.js",
7
7
  "bin": {
@@ -1,5 +1,5 @@
1
1
  export type AgentMessageActivationDecision = "activate" | "silent" | "unclear";
2
- export type AgentMessageActivationReason = "self_message" | "explicit_mention" | "explicit_other_mention" | "broadcast" | "reply_target" | "other_reply_target" | "thread_participant" | "task_owner" | "small_room" | "recent_conversation" | "system_event" | "unaddressed";
2
+ export type AgentMessageActivationReason = "self_message" | "explicit_mention" | "explicit_other_mention" | "broadcast" | "reply_target" | "other_reply_target" | "thread_participant" | "task_owner" | "small_room" | "recent_conversation" | "jev_routed" | "system_event" | "unaddressed";
3
3
  /** Send-time human fallback only; never use this to re-route historical reads. */
4
4
  export declare function humanConversationFallback(input: {
5
5
  source: string | null;
@@ -0,0 +1,518 @@
1
+ <template>
2
+ <section class="rooms-directory" aria-label="Rooms">
3
+ <header class="rooms-heading">
4
+ <div>
5
+ <p class="rooms-parent">
6
+ <span aria-hidden="true">⌂</span> {{ parentLabel }}
7
+ </p>
8
+ <h2>Rooms</h2>
9
+ <p class="rooms-intro">
10
+ Give a task or topic its own conversation. Bring the outcome back
11
+ here.
12
+ </p>
13
+ </div>
14
+ <div class="rooms-heading-actions">
15
+ <button
16
+ v-if="canRefresh"
17
+ class="rooms-icon-button"
18
+ type="button"
19
+ aria-label="Refresh rooms"
20
+ @click="emit('refresh')"
21
+ >
22
+ <svg viewBox="0 0 24 24" aria-hidden="true">
23
+ <path
24
+ d="M20 7v5h-5M4 17v-5h5M6 7a7 7 0 0 1 12-1l2 3M4 15l2 3a7 7 0 0 0 12-1"
25
+ />
26
+ </svg>
27
+ </button>
28
+ <button
29
+ ref="newRoomButton"
30
+ class="rooms-button rooms-button-primary"
31
+ type="button"
32
+ :aria-expanded="composing"
33
+ :aria-controls="`${uid}-create`"
34
+ @click="toggleComposer"
35
+ >
36
+ <span aria-hidden="true">+</span> New room
37
+ </button>
38
+ </div>
39
+ </header>
40
+
41
+ <Transition name="rooms-reveal">
42
+ <section
43
+ v-if="composing"
44
+ :id="`${uid}-create`"
45
+ class="rooms-composer"
46
+ aria-label="New room"
47
+ @keydown.esc.stop.prevent="closeComposer"
48
+ >
49
+ <div class="rooms-composer-heading">
50
+ <div>
51
+ <h3>Make space for focused work</h3>
52
+ <p>The conversation stays connected to {{ parentLabel }}.</p>
53
+ </div>
54
+ <button
55
+ class="rooms-icon-button"
56
+ type="button"
57
+ aria-label="Cancel new room"
58
+ :disabled="busy"
59
+ @click="closeComposer"
60
+ >
61
+ <span aria-hidden="true">×</span>
62
+ </button>
63
+ </div>
64
+ <div class="rooms-mode" aria-label="Room purpose">
65
+ <button
66
+ type="button"
67
+ :aria-pressed="creationMode === 'topic'"
68
+ :disabled="busy"
69
+ @click="setCreationMode('topic')"
70
+ >
71
+ For a topic
72
+ </button>
73
+ <button
74
+ type="button"
75
+ :aria-pressed="creationMode === 'task'"
76
+ :disabled="busy"
77
+ @click="setCreationMode('task')"
78
+ >
79
+ From a task
80
+ </button>
81
+ </div>
82
+ <form
83
+ v-if="creationMode === 'topic'"
84
+ class="rooms-topic-form"
85
+ @submit.prevent="createTopic"
86
+ >
87
+ <label :for="`${uid}-title`">Room name</label>
88
+ <div class="rooms-input-action">
89
+ <input
90
+ :id="`${uid}-title`"
91
+ ref="titleInput"
92
+ v-model="topicTitle"
93
+ placeholder="e.g. Plan the next release"
94
+ autocomplete="off"
95
+ :disabled="busy"
96
+ required
97
+ />
98
+ <button
99
+ class="rooms-button rooms-button-primary"
100
+ type="submit"
101
+ :disabled="busy || !topicTitle.trim()"
102
+ >
103
+ {{ busy ? 'Creating…' : 'Create and open'
104
+ }}<span v-if="!busy" aria-hidden="true">↗</span>
105
+ </button>
106
+ </div>
107
+ <p class="rooms-hint">
108
+ A shared conversation, with its own agents and history. No task
109
+ required.
110
+ </p>
111
+ </form>
112
+ <div v-else class="rooms-task-picker">
113
+ <label class="rooms-search"
114
+ ><svg viewBox="0 0 24 24" aria-hidden="true">
115
+ <circle cx="10.5" cy="10.5" r="6.5" />
116
+ <path d="m16 16 4 4" /></svg
117
+ ><input
118
+ ref="taskSearchInput"
119
+ v-model="taskQuery"
120
+ type="search"
121
+ aria-label="Find a task"
122
+ placeholder="Find a task…"
123
+ :disabled="busy"
124
+ /></label>
125
+ <div
126
+ v-if="visibleTasks.length"
127
+ class="rooms-task-list"
128
+ aria-label="Choose a task"
129
+ >
130
+ <button
131
+ v-for="task in visibleTasks"
132
+ :key="task.id"
133
+ type="button"
134
+ :aria-pressed="selectedTaskId === task.id"
135
+ :disabled="busy"
136
+ @click="selectedTaskId = task.id"
137
+ >
138
+ <span class="rooms-task-radio" aria-hidden="true"></span
139
+ ><span
140
+ ><strong>{{ task.title }}</strong
141
+ ><small>{{
142
+ task.roomId
143
+ ? task.roomClosed
144
+ ? 'Room closed · view its result'
145
+ : 'Room already open'
146
+ : taskStatusLabel(task.status)
147
+ }}</small></span
148
+ ><span v-if="selectedTaskId === task.id" aria-hidden="true"
149
+ >✓</span
150
+ >
151
+ </button>
152
+ </div>
153
+ <p v-else class="rooms-task-empty">
154
+ {{
155
+ taskQuery
156
+ ? 'No tasks match your search.'
157
+ : 'No open tasks yet. Add a task on the board, or start with a topic.'
158
+ }}
159
+ </p>
160
+ <div class="rooms-task-footer">
161
+ <p>
162
+ {{
163
+ selectedTask?.roomId
164
+ ? 'This task already has a room. Continue the conversation there.'
165
+ : 'The task and this conversation stay linked.'
166
+ }}
167
+ </p>
168
+ <button
169
+ class="rooms-button rooms-button-primary"
170
+ type="button"
171
+ :disabled="!selectedTask || busy"
172
+ @click="createTask"
173
+ >
174
+ {{
175
+ busy
176
+ ? 'Opening…'
177
+ : selectedTask?.roomId
178
+ ? 'Open existing room'
179
+ : 'Create and open'
180
+ }}<span v-if="!busy" aria-hidden="true">↗</span>
181
+ </button>
182
+ </div>
183
+ </div>
184
+ </section>
185
+ </Transition>
186
+ <div v-if="error" class="rooms-error" role="alert">
187
+ <p>{{ error }}</p>
188
+ <button
189
+ v-if="createdRoom"
190
+ class="rooms-button"
191
+ type="button"
192
+ :disabled="busy"
193
+ @click="emit('retryOpen')"
194
+ >
195
+ Open {{ createdRoom.title }} <span aria-hidden="true">↗</span>
196
+ </button>
197
+ </div>
198
+
199
+ <div class="rooms-toolbar">
200
+ <div class="rooms-filters" aria-label="Room status">
201
+ <button
202
+ type="button"
203
+ :aria-pressed="!showClosed"
204
+ @click="showClosed = false"
205
+ >
206
+ Open <span>{{ rooms.filter((room) => !room.closed).length }}</span>
207
+ </button>
208
+ <button
209
+ type="button"
210
+ :aria-pressed="showClosed"
211
+ @click="showClosed = true"
212
+ >
213
+ Closed <span>{{ rooms.filter((room) => room.closed).length }}</span>
214
+ </button>
215
+ </div>
216
+ <label class="rooms-search"
217
+ ><svg viewBox="0 0 24 24" aria-hidden="true">
218
+ <circle cx="10.5" cy="10.5" r="6.5" />
219
+ <path d="m16 16 4 4" /></svg
220
+ ><input
221
+ v-model="query"
222
+ type="search"
223
+ aria-label="Search rooms"
224
+ placeholder="Search rooms…"
225
+ /></label>
226
+ </div>
227
+ <div class="rooms-list-caption">
228
+ <span>{{
229
+ showClosed
230
+ ? 'Finished conversations & outcomes'
231
+ : 'Conversations in this room'
232
+ }}</span
233
+ ><span>Newest first</span>
234
+ </div>
235
+ <div v-if="visibleRooms.length" class="rooms-list">
236
+ <article
237
+ v-for="room in visibleRooms"
238
+ :key="room.id"
239
+ class="rooms-entry"
240
+ :data-expanded="expandedId === room.id"
241
+ >
242
+ <div class="rooms-entry-row">
243
+ <button
244
+ class="rooms-entry-main"
245
+ type="button"
246
+ :aria-label="`${room.closed ? 'View result for' : 'Open'} ${room.title}`"
247
+ @click="
248
+ room.closed ? toggleDetails(room.id) : emit('open', room.id)
249
+ "
250
+ >
251
+ <span
252
+ class="rooms-entry-icon"
253
+ :data-kind="room.kind"
254
+ aria-hidden="true"
255
+ ><svg viewBox="0 0 24 24">
256
+ <template v-if="room.closed">
257
+ <path d="m6 12 4 4 8-9" />
258
+ </template>
259
+ <template v-else-if="room.kind === 'branch'">
260
+ <circle cx="7" cy="5" r="2" />
261
+ <circle cx="17" cy="6" r="2" />
262
+ <circle cx="7" cy="19" r="2" />
263
+ <path d="M7 7v10m10-9c0 6-10 2-10 7" />
264
+ </template>
265
+ <template v-else-if="room.kind === 'task'">
266
+ <rect x="5" y="4" width="14" height="17" rx="3" />
267
+ <path d="M9 4V2h6v2M9 12l2 2 4-5" />
268
+ </template>
269
+ <template v-else>
270
+ <path d="M20 11a8 8 0 0 1-8 8H5l-3 2 1-6a8 8 0 1 1 17-4Z" />
271
+ </template></svg
272
+ ></span>
273
+ <span class="rooms-entry-copy"
274
+ ><span class="rooms-entry-title">{{ room.title }}</span
275
+ ><span class="rooms-entry-description"
276
+ ><span class="rooms-mobile-kind"
277
+ >{{ roomKindLabel(room) }} · </span
278
+ >{{
279
+ room.description ||
280
+ (room.closed
281
+ ? 'No outcome recorded. The conversation is still available.'
282
+ : room.kind === 'branch'
283
+ ? 'Conversation for this Git branch'
284
+ : room.kind === 'task'
285
+ ? 'A dedicated conversation for this task'
286
+ : 'A separate space for this topic')
287
+ }}</span
288
+ ></span
289
+ >
290
+ <span class="rooms-entry-kind">{{ roomKindLabel(room) }}</span>
291
+ <time
292
+ class="rooms-entry-date"
293
+ :datetime="
294
+ (room.closed ? room.closedAt : room.createdAt) || undefined
295
+ "
296
+ >{{
297
+ roomDate(room.closed ? room.closedAt : room.createdAt)
298
+ }}</time
299
+ >
300
+ <span class="rooms-entry-arrow" aria-hidden="true">{{
301
+ room.closed ? '↓' : '↗'
302
+ }}</span>
303
+ </button>
304
+ <button
305
+ :id="`${uid}-toggle-${room.id}`"
306
+ class="rooms-icon-button rooms-details-toggle"
307
+ type="button"
308
+ :aria-label="`${expandedId === room.id ? 'Hide' : 'Show'} details for ${room.title}`"
309
+ :aria-expanded="expandedId === room.id"
310
+ :aria-controls="`${uid}-details-${room.id}`"
311
+ @click="toggleDetails(room.id)"
312
+ >
313
+ <svg viewBox="0 0 24 24" aria-hidden="true">
314
+ <path
315
+ :d="expandedId === room.id ? 'm7 14 5-5 5 5' : 'm7 10 5 5 5-5'"
316
+ />
317
+ </svg>
318
+ </button>
319
+ </div>
320
+ <Transition name="rooms-reveal">
321
+ <section
322
+ v-if="expandedId === room.id"
323
+ :id="`${uid}-details-${room.id}`"
324
+ class="rooms-entry-details"
325
+ :aria-label="`${room.title} details`"
326
+ >
327
+ <slot name="details" :room="room"
328
+ ><p>{{ room.description }}</p></slot
329
+ >
330
+ </section>
331
+ </Transition>
332
+ </article>
333
+ </div>
334
+ <div v-else class="rooms-empty">
335
+ <div class="rooms-empty-mark" aria-hidden="true">
336
+ <span></span><span></span><span></span>
337
+ </div>
338
+ <h3>
339
+ {{
340
+ query
341
+ ? 'No rooms match your search'
342
+ : showClosed
343
+ ? 'Outcomes will live here'
344
+ : 'A little room to focus'
345
+ }}
346
+ </h3>
347
+ <p>
348
+ {{
349
+ query
350
+ ? 'Try a room name, task, or branch.'
351
+ : showClosed
352
+ ? 'Close a room when the work is done. Its conversation and result stay available here.'
353
+ : 'Start a conversation for one task or idea, with space for the right people and agents.'
354
+ }}
355
+ </p>
356
+ <button
357
+ v-if="query"
358
+ class="rooms-button"
359
+ type="button"
360
+ @click="query = ''"
361
+ >
362
+ Clear search
363
+ </button>
364
+ <button
365
+ v-else-if="!showClosed"
366
+ class="rooms-button"
367
+ type="button"
368
+ @click="openComposer"
369
+ >
370
+ Create your first room <span aria-hidden="true">↗</span>
371
+ </button>
372
+ </div>
373
+ <details class="rooms-explainer">
374
+ <summary>How rooms work</summary>
375
+ <div>
376
+ <p>
377
+ <strong>One conversation per task or topic.</strong> People and agents
378
+ can join to work together, then record an outcome when the room
379
+ closes.
380
+ </p>
381
+ <p>
382
+ <strong>Still connected.</strong> New rooms share their final note
383
+ back here by default. Change this in a room’s settings.
384
+ </p>
385
+ <p>
386
+ <strong>Git branches have rooms too.</strong> These follow the branch
387
+ agents are working on. Creating a topic room does not create a Git
388
+ branch or a local checkout.
389
+ </p>
390
+ </div>
391
+ </details>
392
+ </section>
393
+ </template>
394
+
395
+ <script setup lang="ts">
396
+ import { computed, nextTick, ref, useId, watch } from 'vue'
397
+ import {
398
+ filterRooms,
399
+ roomDate,
400
+ roomKindLabel,
401
+ taskStatusLabel,
402
+ type DirectoryRoom,
403
+ type DirectoryTask,
404
+ } from './directory'
405
+
406
+ const props = defineProps<{
407
+ rooms: readonly DirectoryRoom[]
408
+ tasks: readonly DirectoryTask[]
409
+ parentLabel: string
410
+ busy?: boolean
411
+ error?: string | null
412
+ canRefresh?: boolean
413
+ initialTaskId?: string | null
414
+ createdRoom?: { id: string; title: string } | null
415
+ }>()
416
+ const emit = defineEmits<{
417
+ open: [id: string]
418
+ select: [id: string | null]
419
+ createTopic: [title: string]
420
+ createTask: [id: string]
421
+ refresh: []
422
+ retryOpen: []
423
+ }>()
424
+ const uid = useId()
425
+ const composing = ref(false)
426
+ const creationMode = ref<'topic' | 'task'>('topic')
427
+ const topicTitle = ref('')
428
+ const query = ref('')
429
+ const taskQuery = ref('')
430
+ const showClosed = ref(false)
431
+ const expandedId = ref<string | null>(null)
432
+ const selectedTaskId = ref<string | null>(null)
433
+ const newRoomButton = ref<HTMLButtonElement | null>(null)
434
+ const titleInput = ref<HTMLInputElement | null>(null)
435
+ const taskSearchInput = ref<HTMLInputElement | null>(null)
436
+ const visibleRooms = computed(() =>
437
+ filterRooms(props.rooms, showClosed.value, query.value),
438
+ )
439
+ const visibleTasks = computed(() =>
440
+ props.tasks.filter((task) =>
441
+ `${task.title} ${task.description}`
442
+ .toLocaleLowerCase()
443
+ .includes(taskQuery.value.trim().toLocaleLowerCase()),
444
+ ),
445
+ )
446
+ const selectedTask = computed(() =>
447
+ visibleTasks.value.find((task) => task.id === selectedTaskId.value),
448
+ )
449
+
450
+ watch(
451
+ () => props.initialTaskId,
452
+ (id) => {
453
+ if (!id) return
454
+ taskQuery.value = ''
455
+ selectedTaskId.value = id
456
+ creationMode.value = 'task'
457
+ composing.value = true
458
+ },
459
+ { immediate: true },
460
+ )
461
+ watch(visibleRooms, (rooms) => {
462
+ if (expandedId.value && !rooms.some((room) => room.id === expandedId.value)) {
463
+ expandedId.value = null
464
+ emit('select', null)
465
+ }
466
+ })
467
+ watch(
468
+ visibleTasks,
469
+ (tasks) => {
470
+ if (
471
+ selectedTaskId.value &&
472
+ !tasks.some((task) => task.id === selectedTaskId.value)
473
+ )
474
+ selectedTaskId.value = null
475
+ },
476
+ { flush: 'sync' },
477
+ )
478
+ async function openComposer() {
479
+ composing.value = true
480
+ await focusComposer()
481
+ }
482
+ async function focusComposer() {
483
+ await nextTick()
484
+ ;(creationMode.value === 'topic'
485
+ ? titleInput.value
486
+ : taskSearchInput.value
487
+ )?.focus()
488
+ }
489
+ function toggleComposer() {
490
+ if (composing.value) closeComposer()
491
+ else void openComposer()
492
+ }
493
+ async function closeComposer() {
494
+ if (props.busy) return
495
+ composing.value = false
496
+ await nextTick()
497
+ newRoomButton.value?.focus()
498
+ }
499
+ async function setCreationMode(mode: 'topic' | 'task') {
500
+ creationMode.value = mode
501
+ await focusComposer()
502
+ }
503
+ function toggleDetails(id: string) {
504
+ expandedId.value = expandedId.value === id ? null : id
505
+ emit('select', expandedId.value)
506
+ }
507
+ function createTopic() {
508
+ if (!props.busy && topicTitle.value.trim())
509
+ emit('createTopic', topicTitle.value.trim())
510
+ }
511
+ function createTask() {
512
+ if (props.busy || !selectedTask.value) return
513
+ if (selectedTask.value.roomId) emit('open', selectedTask.value.roomId)
514
+ else emit('createTask', selectedTask.value.id)
515
+ }
516
+ </script>
517
+
518
+ <style src="./rooms-directory.css"></style>