@agent-marshal/marshal-prime-agent 0.54.1 → 0.55.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.
package/package.json CHANGED
@@ -50,5 +50,5 @@
50
50
  "test": "bun test"
51
51
  },
52
52
  "type": "module",
53
- "version": "0.54.1"
53
+ "version": "0.55.1"
54
54
  }
package/src/daemon.ts CHANGED
@@ -41,9 +41,11 @@ import {
41
41
  type SessionNicknameItem,
42
42
  } from "./entities.ts";
43
43
  import type { Identity } from "./identity.ts";
44
+ import { InboxPuller, type InboxPull } from "./inbox-pull.ts";
44
45
 
45
46
  const LIVENESS_INTERVAL_MS = 5_000;
46
47
  const INBOX_PULL_LIMIT = 20;
48
+ const INBOX_COMMAND_TIMEOUT_MS = 1_500;
47
49
  const SOURCE_ID = "marshal-prime-agent";
48
50
 
49
51
  /** myko's CellServer serves its WebSocket at the `/myko` path. */
@@ -78,7 +80,11 @@ export class MarshalDaemon {
78
80
  private roster: SessionItem[] = [];
79
81
  private nicknames: SessionNicknameItem[] = [];
80
82
 
81
- private readonly draining = new Map<string, Promise<string | null>>();
83
+ private readonly inbox = new InboxPuller<MessageView>({
84
+ read: (sessionId) => this.readUnread(sessionId),
85
+ ack: async (sessionId, messageIds) => { await this.sendForInbox(ackMessages(sessionId, messageIds)); },
86
+ render: (messages) => this.renderInbox(messages),
87
+ });
82
88
  private notifyHandler: NotifyHandler | null = null;
83
89
  private livenessTimer: ReturnType<typeof setInterval> | null = null;
84
90
  private started = false;
@@ -250,12 +256,14 @@ export class MarshalDaemon {
250
256
  return `- [${m.messageId}] ${this.senderLabel(m.fromSessionId)} [response_required=${m.responseRequired ?? false}]: ${m.body}`;
251
257
  }
252
258
 
253
- drainInbox(sessionId: string): Promise<string | null> {
254
- const prev = this.draining.get(sessionId) ?? Promise.resolve<string | null>(null);
255
- const next = prev.catch(() => null).then(() => this.drainInboxInner(sessionId));
256
- this.draining.set(sessionId, next);
257
- void next.finally(() => { if (this.draining.get(sessionId) === next) this.draining.delete(sessionId); });
258
- return next;
259
+ /**
260
+ * Read the unread inbox without acking it. Commit the pull once its text is
261
+ * in the conversation, or release it if it never gets there, so a message is
262
+ * never marked read without having been shown.
263
+ */
264
+ pullInbox(sessionId: string): Promise<InboxPull | null> {
265
+ if (!this.connected) return Promise.resolve(null);
266
+ return this.inbox.pull(sessionId).catch(() => null);
259
267
  }
260
268
 
261
269
  nicknameFor(sessionId: string): string {
@@ -270,11 +278,11 @@ export class MarshalDaemon {
270
278
 
271
279
  // ── Internals ──────────────────────────────────────────────────────────
272
280
 
273
- private async drainInboxInner(sessionId: string): Promise<string | null> {
281
+ private async readUnread(sessionId: string): Promise<MessageView[] | null> {
274
282
  let result: ReadMessagesResult | undefined;
275
283
  for (let attempt = 0; attempt < 3; attempt++) {
276
284
  try {
277
- result = await this.send(readMessages({
285
+ result = await this.sendForInbox(readMessages({
278
286
  asSession: sessionId,
279
287
  toSession: sessionId,
280
288
  inbox: false,
@@ -288,13 +296,7 @@ export class MarshalDaemon {
288
296
  if (result.messages && result.messages.length > 0) break;
289
297
  if (attempt < 2) await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
290
298
  }
291
- if (!result?.messages?.length) return null;
292
-
293
- const block = this.renderInbox(result.messages);
294
- try {
295
- await this.send(ackMessages(sessionId, result.messages.map((m) => m.messageId)));
296
- } catch { this.log("inbox ack failed"); }
297
- return block;
299
+ return result?.messages?.length ? result.messages : null;
298
300
  }
299
301
 
300
302
  private renderInbox(messages: MessageView[]): string {
@@ -352,6 +354,38 @@ export class MarshalDaemon {
352
354
  return this.client.sendCommand(command as never) as Promise<R>;
353
355
  }
354
356
 
357
+ /** Bounded send for the inbox path: reject at once while the WS is down, and
358
+ * otherwise time out, so a read that never answers can't hold up every later
359
+ * pull for the session. */
360
+ private sendForInbox<R>(command: MarshalCommand<R>): Promise<R> {
361
+ if (!this.connected) return Promise.reject(new Error("marshal is disconnected"));
362
+
363
+ return new Promise<R>((resolve, reject) => {
364
+ let settled = false;
365
+ const timer = setTimeout(() => {
366
+ if (settled) return;
367
+ settled = true;
368
+ reject(new Error(`marshal inbox command timed out after ${INBOX_COMMAND_TIMEOUT_MS}ms`));
369
+ }, INBOX_COMMAND_TIMEOUT_MS);
370
+ if (typeof (timer as any).unref === "function") (timer as any).unref();
371
+
372
+ void this.send(command).then(
373
+ (value) => {
374
+ if (settled) return;
375
+ settled = true;
376
+ clearTimeout(timer);
377
+ resolve(value);
378
+ },
379
+ (error: unknown) => {
380
+ if (settled) return;
381
+ settled = true;
382
+ clearTimeout(timer);
383
+ reject(error);
384
+ },
385
+ );
386
+ });
387
+ }
388
+
355
389
  private watch<I>(query: MarshalQuery<I>) {
356
390
  return this.client.watchQuery(query as never) as unknown as import("rxjs").Observable<I[]>;
357
391
  }
@@ -26,28 +26,30 @@ export type Message = {
26
26
  * pull it. In the pull-via-hook model the recipient reads on its
27
27
  * next turn — which can be after the sender's SessionEnd — so
28
28
  * cascading on the sender would silently delete unread messages in
29
- * that window. The message's lifetime is governed by the *recipient*
30
- * instead (the `to_session_id` / `to_room_id` cascades below).
29
+ * that window. No recipient-session cascade either (see `to_session_id`).
31
30
  */
32
31
  fromSessionId: SessionId,
33
32
  /**
34
33
  * Direct recipient — set for 1:1 sends, `None` for broadcasts.
35
- * Cascade-DELs the message when the recipient session is DEL'd.
34
+ * NOT a `belongs_to(Session)` cascade: a recipient's session row is DEL'd
35
+ * and re-SET under the same id on a reload, a resume or a reaped pull
36
+ * session's next hook, and its direct messages must still be there. A
37
+ * message lives until the daemon's TTL sweep prunes it.
36
38
  */
37
39
  toSessionId?: SessionId | null,
38
40
  /**
39
41
  * Broadcast recipient — set for room sends, `None` for direct
40
42
  * sends. Cascade-DELs when the room is DEL'd. (Auto-rooms like
41
43
  * `everyone` and `host:*` are never DEL'd, so broadcasts to them
42
- * effectively persist forever.)
44
+ * persist until the daemon's TTL sweep prunes them.)
43
45
  */
44
46
  toRoomId?: RoomId | null,
45
47
  /**
46
48
  * When this direct message was addressed to a *human* — via their operator
47
49
  * identity (the `op:`/`human:`/email tier of recipient resolution) rather
48
50
  * than to one specific agent — this holds that operator string (e.g.
49
- * `max@lucid.rocks`). The message is still routed to, and cascades on, the
50
- * operator's most-active session via `to_session_id`; this marks it as
51
+ * `max@lucid.rocks`). The message is still routed to the operator's
52
+ * most-active session via `to_session_id`; this marks it as
51
53
  * human-addressed so the receiving agent surfaces it to its operator
52
54
  * instead of treating it as ordinary peer chatter, and the UI console can
53
55
  * toast it for that operator regardless of which agent it landed on. `None`
@@ -3,7 +3,24 @@ import type { MessageId } from "./MessageId";
3
3
  import type { MessageReadId } from "./MessageReadId";
4
4
  import type { SessionId } from "./SessionId";
5
5
 
6
- export type MessageRead = { messageId: MessageId, sessionId: SessionId,
6
+ export type MessageRead = {
7
+ /**
8
+ * Cascade on the *message*: an ack is meaningless once its message is gone,
9
+ * so it dies with the message. This is the ack's primary lifetime owner.
10
+ * The TTL prune deletes messages without cascading, so the daemon's
11
+ * cleanup also sweeps acks whose message is gone (`sweep_orphan_reads`).
12
+ */
13
+ messageId: MessageId,
14
+ /**
15
+ * NOT a `belongs_to(Session)` cascade: an ack is a fact about (message,
16
+ * reader) and outlives the reader's session row. Harnesses re-register
17
+ * under the same session id after a reload or resume (marshal-pi across
18
+ * `/reload`, the Claude shim on `--resume`, a pull session repaired after
19
+ * the reap backstop), so a session DEL is often a reconnect. Cascading
20
+ * here made every re-registration see the direct messages it had already
21
+ * read as unread, and the inbox handed them over again.
22
+ */
23
+ sessionId: SessionId,
7
24
  /**
8
25
  * Wall-clock millis when this session marked the message read.
9
26
  */
@@ -217,84 +217,6 @@ import type { SetSessionLastToolAt as _SetSessionLastToolAt } from "./SetSession
217
217
  import type { SetSessionSessionName as _SetSessionSessionName } from "./SetSessionSessionName";
218
218
 
219
219
  // Query classes
220
- export class GetSessionsByQuery {
221
- static readonly queryId = "GetSessionsByQuery" as const;
222
- static readonly queryItemType = "Session" as const;
223
- readonly queryId = "GetSessionsByQuery" as const;
224
- readonly queryItemType = "Session" as const;
225
- readonly query: Omit<_GetSessionsByQuery, "tx" | "createdAt">;
226
- declare readonly $res: () => Session[];
227
-
228
- constructor(args: Omit<_GetSessionsByQuery, "tx" | "createdAt">) {
229
- this.query = args;
230
- }
231
- }
232
-
233
- export class GetSessionsByIds {
234
- static readonly queryId = "GetSessionsByIds" as const;
235
- static readonly queryItemType = "Session" as const;
236
- readonly queryId = "GetSessionsByIds" as const;
237
- readonly queryItemType = "Session" as const;
238
- readonly query: Omit<_GetSessionsByIds, "tx" | "createdAt">;
239
- declare readonly $res: () => Session[];
240
-
241
- constructor(args: Omit<_GetSessionsByIds, "tx" | "createdAt">) {
242
- this.query = args;
243
- }
244
- }
245
-
246
- export class GetAllSessions {
247
- static readonly queryId = "GetAllSessions" as const;
248
- static readonly queryItemType = "Session" as const;
249
- readonly queryId = "GetAllSessions" as const;
250
- readonly queryItemType = "Session" as const;
251
- readonly query: Omit<_GetAllSessions, "tx" | "createdAt">;
252
- declare readonly $res: () => Session[];
253
-
254
- constructor(args: Omit<_GetAllSessions, "tx" | "createdAt">) {
255
- this.query = args;
256
- }
257
- }
258
-
259
- export class GetMessageReadsByQuery {
260
- static readonly queryId = "GetMessageReadsByQuery" as const;
261
- static readonly queryItemType = "MessageRead" as const;
262
- readonly queryId = "GetMessageReadsByQuery" as const;
263
- readonly queryItemType = "MessageRead" as const;
264
- readonly query: Omit<_GetMessageReadsByQuery, "tx" | "createdAt">;
265
- declare readonly $res: () => MessageRead[];
266
-
267
- constructor(args: Omit<_GetMessageReadsByQuery, "tx" | "createdAt">) {
268
- this.query = args;
269
- }
270
- }
271
-
272
- export class GetMessageReadsByIds {
273
- static readonly queryId = "GetMessageReadsByIds" as const;
274
- static readonly queryItemType = "MessageRead" as const;
275
- readonly queryId = "GetMessageReadsByIds" as const;
276
- readonly queryItemType = "MessageRead" as const;
277
- readonly query: Omit<_GetMessageReadsByIds, "tx" | "createdAt">;
278
- declare readonly $res: () => MessageRead[];
279
-
280
- constructor(args: Omit<_GetMessageReadsByIds, "tx" | "createdAt">) {
281
- this.query = args;
282
- }
283
- }
284
-
285
- export class GetAllMessageReads {
286
- static readonly queryId = "GetAllMessageReads" as const;
287
- static readonly queryItemType = "MessageRead" as const;
288
- readonly queryId = "GetAllMessageReads" as const;
289
- readonly queryItemType = "MessageRead" as const;
290
- readonly query: Omit<_GetAllMessageReads, "tx" | "createdAt">;
291
- declare readonly $res: () => MessageRead[];
292
-
293
- constructor(args: Omit<_GetAllMessageReads, "tx" | "createdAt">) {
294
- this.query = args;
295
- }
296
- }
297
-
298
220
  export class GetSessionNicknamesByQuery {
299
221
  static readonly queryId = "GetSessionNicknamesByQuery" as const;
300
222
  static readonly queryItemType = "SessionNickname" as const;
@@ -490,75 +412,87 @@ export class GetAllMessages {
490
412
  }
491
413
  }
492
414
 
493
- // View classes
494
-
495
- // Report classes
496
- export class GetSessionById {
497
- static readonly reportId = "GetSessionById" as const;
498
- readonly reportId = "GetSessionById" as const;
499
- readonly report: Omit<_GetSessionById, "tx">;
500
- declare readonly $res: () => Session | null;
415
+ export class GetSessionsByQuery {
416
+ static readonly queryId = "GetSessionsByQuery" as const;
417
+ static readonly queryItemType = "Session" as const;
418
+ readonly queryId = "GetSessionsByQuery" as const;
419
+ readonly queryItemType = "Session" as const;
420
+ readonly query: Omit<_GetSessionsByQuery, "tx" | "createdAt">;
421
+ declare readonly $res: () => Session[];
501
422
 
502
- constructor(args: Omit<_GetSessionById, "tx">) {
503
- this.report = args;
423
+ constructor(args: Omit<_GetSessionsByQuery, "tx" | "createdAt">) {
424
+ this.query = args;
504
425
  }
505
426
  }
506
427
 
507
- export class CountSessions {
508
- static readonly reportId = "CountSessions" as const;
509
- readonly reportId = "CountSessions" as const;
510
- readonly report: Omit<_CountSessions, "tx">;
511
- declare readonly $res: () => SessionCount;
428
+ export class GetSessionsByIds {
429
+ static readonly queryId = "GetSessionsByIds" as const;
430
+ static readonly queryItemType = "Session" as const;
431
+ readonly queryId = "GetSessionsByIds" as const;
432
+ readonly queryItemType = "Session" as const;
433
+ readonly query: Omit<_GetSessionsByIds, "tx" | "createdAt">;
434
+ declare readonly $res: () => Session[];
512
435
 
513
- constructor(args: Omit<_CountSessions, "tx">) {
514
- this.report = args;
436
+ constructor(args: Omit<_GetSessionsByIds, "tx" | "createdAt">) {
437
+ this.query = args;
515
438
  }
516
439
  }
517
440
 
518
- export class CountAllSessions {
519
- static readonly reportId = "CountAllSessions" as const;
520
- readonly reportId = "CountAllSessions" as const;
521
- readonly report: Omit<_CountAllSessions, "tx">;
522
- declare readonly $res: () => SessionCount;
441
+ export class GetAllSessions {
442
+ static readonly queryId = "GetAllSessions" as const;
443
+ static readonly queryItemType = "Session" as const;
444
+ readonly queryId = "GetAllSessions" as const;
445
+ readonly queryItemType = "Session" as const;
446
+ readonly query: Omit<_GetAllSessions, "tx" | "createdAt">;
447
+ declare readonly $res: () => Session[];
523
448
 
524
- constructor(args: Omit<_CountAllSessions, "tx">) {
525
- this.report = args;
449
+ constructor(args: Omit<_GetAllSessions, "tx" | "createdAt">) {
450
+ this.query = args;
526
451
  }
527
452
  }
528
453
 
529
- export class GetMessageReadById {
530
- static readonly reportId = "GetMessageReadById" as const;
531
- readonly reportId = "GetMessageReadById" as const;
532
- readonly report: Omit<_GetMessageReadById, "tx">;
533
- declare readonly $res: () => MessageRead | null;
454
+ export class GetMessageReadsByQuery {
455
+ static readonly queryId = "GetMessageReadsByQuery" as const;
456
+ static readonly queryItemType = "MessageRead" as const;
457
+ readonly queryId = "GetMessageReadsByQuery" as const;
458
+ readonly queryItemType = "MessageRead" as const;
459
+ readonly query: Omit<_GetMessageReadsByQuery, "tx" | "createdAt">;
460
+ declare readonly $res: () => MessageRead[];
534
461
 
535
- constructor(args: Omit<_GetMessageReadById, "tx">) {
536
- this.report = args;
462
+ constructor(args: Omit<_GetMessageReadsByQuery, "tx" | "createdAt">) {
463
+ this.query = args;
537
464
  }
538
465
  }
539
466
 
540
- export class CountMessageReads {
541
- static readonly reportId = "CountMessageReads" as const;
542
- readonly reportId = "CountMessageReads" as const;
543
- readonly report: Omit<_CountMessageReads, "tx">;
544
- declare readonly $res: () => MessageReadCount;
467
+ export class GetMessageReadsByIds {
468
+ static readonly queryId = "GetMessageReadsByIds" as const;
469
+ static readonly queryItemType = "MessageRead" as const;
470
+ readonly queryId = "GetMessageReadsByIds" as const;
471
+ readonly queryItemType = "MessageRead" as const;
472
+ readonly query: Omit<_GetMessageReadsByIds, "tx" | "createdAt">;
473
+ declare readonly $res: () => MessageRead[];
545
474
 
546
- constructor(args: Omit<_CountMessageReads, "tx">) {
547
- this.report = args;
475
+ constructor(args: Omit<_GetMessageReadsByIds, "tx" | "createdAt">) {
476
+ this.query = args;
548
477
  }
549
478
  }
550
479
 
551
- export class CountAllMessageReads {
552
- static readonly reportId = "CountAllMessageReads" as const;
553
- readonly reportId = "CountAllMessageReads" as const;
554
- readonly report: Omit<_CountAllMessageReads, "tx">;
555
- declare readonly $res: () => MessageReadCount;
480
+ export class GetAllMessageReads {
481
+ static readonly queryId = "GetAllMessageReads" as const;
482
+ static readonly queryItemType = "MessageRead" as const;
483
+ readonly queryId = "GetAllMessageReads" as const;
484
+ readonly queryItemType = "MessageRead" as const;
485
+ readonly query: Omit<_GetAllMessageReads, "tx" | "createdAt">;
486
+ declare readonly $res: () => MessageRead[];
556
487
 
557
- constructor(args: Omit<_CountAllMessageReads, "tx">) {
558
- this.report = args;
488
+ constructor(args: Omit<_GetAllMessageReads, "tx" | "createdAt">) {
489
+ this.query = args;
559
490
  }
560
491
  }
561
492
 
493
+ // View classes
494
+
495
+ // Report classes
562
496
  export class GetSessionNicknameById {
563
497
  static readonly reportId = "GetSessionNicknameById" as const;
564
498
  readonly reportId = "GetSessionNicknameById" as const;
@@ -724,150 +658,73 @@ export class CountAllMessages {
724
658
  }
725
659
  }
726
660
 
727
- // Command classes
728
- export class BroadcastMessage {
729
- static readonly commandId = "BroadcastMessage" as const;
730
- readonly commandId = "BroadcastMessage" as const;
731
- readonly command: Omit<_BroadcastMessage, "tx" | "createdAt">;
732
- declare readonly $res: () => BroadcastMessageResult;
733
-
734
- constructor(args: Omit<_BroadcastMessage, "tx" | "createdAt">) {
735
- this.command = args;
736
- }
737
- }
738
-
739
- export class SendMessage {
740
- static readonly commandId = "SendMessage" as const;
741
- readonly commandId = "SendMessage" as const;
742
- readonly command: Omit<_SendMessage, "tx" | "createdAt">;
743
- declare readonly $res: () => SendMessageResult;
744
-
745
- constructor(args: Omit<_SendMessage, "tx" | "createdAt">) {
746
- this.command = args;
747
- }
748
- }
749
-
750
- export class SetSessionLastToolAt {
751
- static readonly commandId = "SetSessionLastToolAt" as const;
752
- readonly commandId = "SetSessionLastToolAt" as const;
753
- readonly command: Omit<_SetSessionLastToolAt, "tx" | "createdAt">;
754
- declare readonly $res: () => void;
755
-
756
- constructor(args: Omit<_SetSessionLastToolAt, "tx" | "createdAt">) {
757
- this.command = args;
758
- }
759
- }
760
-
761
- export class SetSessionLastTool {
762
- static readonly commandId = "SetSessionLastTool" as const;
763
- readonly commandId = "SetSessionLastTool" as const;
764
- readonly command: Omit<_SetSessionLastTool, "tx" | "createdAt">;
765
- declare readonly $res: () => void;
766
-
767
- constructor(args: Omit<_SetSessionLastTool, "tx" | "createdAt">) {
768
- this.command = args;
769
- }
770
- }
771
-
772
- export class SetSessionLastActivityAt {
773
- static readonly commandId = "SetSessionLastActivityAt" as const;
774
- readonly commandId = "SetSessionLastActivityAt" as const;
775
- readonly command: Omit<_SetSessionLastActivityAt, "tx" | "createdAt">;
776
- declare readonly $res: () => void;
777
-
778
- constructor(args: Omit<_SetSessionLastActivityAt, "tx" | "createdAt">) {
779
- this.command = args;
780
- }
781
- }
782
-
783
- export class SetSessionGitBranch {
784
- static readonly commandId = "SetSessionGitBranch" as const;
785
- readonly commandId = "SetSessionGitBranch" as const;
786
- readonly command: Omit<_SetSessionGitBranch, "tx" | "createdAt">;
787
- declare readonly $res: () => void;
788
-
789
- constructor(args: Omit<_SetSessionGitBranch, "tx" | "createdAt">) {
790
- this.command = args;
791
- }
792
- }
793
-
794
- export class DeleteSessions {
795
- static readonly commandId = "DeleteSessions" as const;
796
- readonly commandId = "DeleteSessions" as const;
797
- readonly command: Omit<_DeleteSessions, "tx" | "createdAt">;
798
- declare readonly $res: () => DeleteSessionsResult;
799
-
800
- constructor(args: Omit<_DeleteSessions, "tx" | "createdAt">) {
801
- this.command = args;
802
- }
803
- }
804
-
805
- export class SetSessionActivity {
806
- static readonly commandId = "SetSessionActivity" as const;
807
- readonly commandId = "SetSessionActivity" as const;
808
- readonly command: Omit<_SetSessionActivity, "tx" | "createdAt">;
809
- declare readonly $res: () => void;
661
+ export class GetSessionById {
662
+ static readonly reportId = "GetSessionById" as const;
663
+ readonly reportId = "GetSessionById" as const;
664
+ readonly report: Omit<_GetSessionById, "tx">;
665
+ declare readonly $res: () => Session | null;
810
666
 
811
- constructor(args: Omit<_SetSessionActivity, "tx" | "createdAt">) {
812
- this.command = args;
667
+ constructor(args: Omit<_GetSessionById, "tx">) {
668
+ this.report = args;
813
669
  }
814
670
  }
815
671
 
816
- export class SetSessionSessionName {
817
- static readonly commandId = "SetSessionSessionName" as const;
818
- readonly commandId = "SetSessionSessionName" as const;
819
- readonly command: Omit<_SetSessionSessionName, "tx" | "createdAt">;
820
- declare readonly $res: () => void;
672
+ export class CountSessions {
673
+ static readonly reportId = "CountSessions" as const;
674
+ readonly reportId = "CountSessions" as const;
675
+ readonly report: Omit<_CountSessions, "tx">;
676
+ declare readonly $res: () => SessionCount;
821
677
 
822
- constructor(args: Omit<_SetSessionSessionName, "tx" | "createdAt">) {
823
- this.command = args;
678
+ constructor(args: Omit<_CountSessions, "tx">) {
679
+ this.report = args;
824
680
  }
825
681
  }
826
682
 
827
- export class SetSessionCurrentTask {
828
- static readonly commandId = "SetSessionCurrentTask" as const;
829
- readonly commandId = "SetSessionCurrentTask" as const;
830
- readonly command: Omit<_SetSessionCurrentTask, "tx" | "createdAt">;
831
- declare readonly $res: () => void;
683
+ export class CountAllSessions {
684
+ static readonly reportId = "CountAllSessions" as const;
685
+ readonly reportId = "CountAllSessions" as const;
686
+ readonly report: Omit<_CountAllSessions, "tx">;
687
+ declare readonly $res: () => SessionCount;
832
688
 
833
- constructor(args: Omit<_SetSessionCurrentTask, "tx" | "createdAt">) {
834
- this.command = args;
689
+ constructor(args: Omit<_CountAllSessions, "tx">) {
690
+ this.report = args;
835
691
  }
836
692
  }
837
693
 
838
- export class DeleteSession {
839
- static readonly commandId = "DeleteSession" as const;
840
- readonly commandId = "DeleteSession" as const;
841
- readonly command: Omit<_DeleteSession, "tx" | "createdAt">;
842
- declare readonly $res: () => DeleteSessionResult;
694
+ export class GetMessageReadById {
695
+ static readonly reportId = "GetMessageReadById" as const;
696
+ readonly reportId = "GetMessageReadById" as const;
697
+ readonly report: Omit<_GetMessageReadById, "tx">;
698
+ declare readonly $res: () => MessageRead | null;
843
699
 
844
- constructor(args: Omit<_DeleteSession, "tx" | "createdAt">) {
845
- this.command = args;
700
+ constructor(args: Omit<_GetMessageReadById, "tx">) {
701
+ this.report = args;
846
702
  }
847
703
  }
848
704
 
849
- export class DeleteMessageRead {
850
- static readonly commandId = "DeleteMessageRead" as const;
851
- readonly commandId = "DeleteMessageRead" as const;
852
- readonly command: Omit<_DeleteMessageRead, "tx" | "createdAt">;
853
- declare readonly $res: () => DeleteMessageReadResult;
705
+ export class CountMessageReads {
706
+ static readonly reportId = "CountMessageReads" as const;
707
+ readonly reportId = "CountMessageReads" as const;
708
+ readonly report: Omit<_CountMessageReads, "tx">;
709
+ declare readonly $res: () => MessageReadCount;
854
710
 
855
- constructor(args: Omit<_DeleteMessageRead, "tx" | "createdAt">) {
856
- this.command = args;
711
+ constructor(args: Omit<_CountMessageReads, "tx">) {
712
+ this.report = args;
857
713
  }
858
714
  }
859
715
 
860
- export class DeleteMessageReads {
861
- static readonly commandId = "DeleteMessageReads" as const;
862
- readonly commandId = "DeleteMessageReads" as const;
863
- readonly command: Omit<_DeleteMessageReads, "tx" | "createdAt">;
864
- declare readonly $res: () => DeleteMessageReadsResult;
716
+ export class CountAllMessageReads {
717
+ static readonly reportId = "CountAllMessageReads" as const;
718
+ readonly reportId = "CountAllMessageReads" as const;
719
+ readonly report: Omit<_CountAllMessageReads, "tx">;
720
+ declare readonly $res: () => MessageReadCount;
865
721
 
866
- constructor(args: Omit<_DeleteMessageReads, "tx" | "createdAt">) {
867
- this.command = args;
722
+ constructor(args: Omit<_CountAllMessageReads, "tx">) {
723
+ this.report = args;
868
724
  }
869
725
  }
870
726
 
727
+ // Command classes
871
728
  export class DeleteSessionNickname {
872
729
  static readonly commandId = "DeleteSessionNickname" as const;
873
730
  readonly commandId = "DeleteSessionNickname" as const;
@@ -945,6 +802,28 @@ export class JoinRoom {
945
802
  }
946
803
  }
947
804
 
805
+ export class BroadcastMessage {
806
+ static readonly commandId = "BroadcastMessage" as const;
807
+ readonly commandId = "BroadcastMessage" as const;
808
+ readonly command: Omit<_BroadcastMessage, "tx" | "createdAt">;
809
+ declare readonly $res: () => BroadcastMessageResult;
810
+
811
+ constructor(args: Omit<_BroadcastMessage, "tx" | "createdAt">) {
812
+ this.command = args;
813
+ }
814
+ }
815
+
816
+ export class SendMessage {
817
+ static readonly commandId = "SendMessage" as const;
818
+ readonly commandId = "SendMessage" as const;
819
+ readonly command: Omit<_SendMessage, "tx" | "createdAt">;
820
+ declare readonly $res: () => SendMessageResult;
821
+
822
+ constructor(args: Omit<_SendMessage, "tx" | "createdAt">) {
823
+ this.command = args;
824
+ }
825
+ }
826
+
948
827
  export class DeleteRoom {
949
828
  static readonly commandId = "DeleteRoom" as const;
950
829
  readonly commandId = "DeleteRoom" as const;
@@ -1066,15 +945,136 @@ export class NotifyChannel {
1066
945
  }
1067
946
  }
1068
947
 
948
+ export class SetSessionLastToolAt {
949
+ static readonly commandId = "SetSessionLastToolAt" as const;
950
+ readonly commandId = "SetSessionLastToolAt" as const;
951
+ readonly command: Omit<_SetSessionLastToolAt, "tx" | "createdAt">;
952
+ declare readonly $res: () => void;
953
+
954
+ constructor(args: Omit<_SetSessionLastToolAt, "tx" | "createdAt">) {
955
+ this.command = args;
956
+ }
957
+ }
958
+
959
+ export class SetSessionLastTool {
960
+ static readonly commandId = "SetSessionLastTool" as const;
961
+ readonly commandId = "SetSessionLastTool" as const;
962
+ readonly command: Omit<_SetSessionLastTool, "tx" | "createdAt">;
963
+ declare readonly $res: () => void;
964
+
965
+ constructor(args: Omit<_SetSessionLastTool, "tx" | "createdAt">) {
966
+ this.command = args;
967
+ }
968
+ }
969
+
970
+ export class SetSessionLastActivityAt {
971
+ static readonly commandId = "SetSessionLastActivityAt" as const;
972
+ readonly commandId = "SetSessionLastActivityAt" as const;
973
+ readonly command: Omit<_SetSessionLastActivityAt, "tx" | "createdAt">;
974
+ declare readonly $res: () => void;
975
+
976
+ constructor(args: Omit<_SetSessionLastActivityAt, "tx" | "createdAt">) {
977
+ this.command = args;
978
+ }
979
+ }
980
+
981
+ export class SetSessionGitBranch {
982
+ static readonly commandId = "SetSessionGitBranch" as const;
983
+ readonly commandId = "SetSessionGitBranch" as const;
984
+ readonly command: Omit<_SetSessionGitBranch, "tx" | "createdAt">;
985
+ declare readonly $res: () => void;
986
+
987
+ constructor(args: Omit<_SetSessionGitBranch, "tx" | "createdAt">) {
988
+ this.command = args;
989
+ }
990
+ }
991
+
992
+ export class DeleteSessions {
993
+ static readonly commandId = "DeleteSessions" as const;
994
+ readonly commandId = "DeleteSessions" as const;
995
+ readonly command: Omit<_DeleteSessions, "tx" | "createdAt">;
996
+ declare readonly $res: () => DeleteSessionsResult;
997
+
998
+ constructor(args: Omit<_DeleteSessions, "tx" | "createdAt">) {
999
+ this.command = args;
1000
+ }
1001
+ }
1002
+
1003
+ export class SetSessionActivity {
1004
+ static readonly commandId = "SetSessionActivity" as const;
1005
+ readonly commandId = "SetSessionActivity" as const;
1006
+ readonly command: Omit<_SetSessionActivity, "tx" | "createdAt">;
1007
+ declare readonly $res: () => void;
1008
+
1009
+ constructor(args: Omit<_SetSessionActivity, "tx" | "createdAt">) {
1010
+ this.command = args;
1011
+ }
1012
+ }
1013
+
1014
+ export class SetSessionSessionName {
1015
+ static readonly commandId = "SetSessionSessionName" as const;
1016
+ readonly commandId = "SetSessionSessionName" as const;
1017
+ readonly command: Omit<_SetSessionSessionName, "tx" | "createdAt">;
1018
+ declare readonly $res: () => void;
1019
+
1020
+ constructor(args: Omit<_SetSessionSessionName, "tx" | "createdAt">) {
1021
+ this.command = args;
1022
+ }
1023
+ }
1024
+
1025
+ export class SetSessionCurrentTask {
1026
+ static readonly commandId = "SetSessionCurrentTask" as const;
1027
+ readonly commandId = "SetSessionCurrentTask" as const;
1028
+ readonly command: Omit<_SetSessionCurrentTask, "tx" | "createdAt">;
1029
+ declare readonly $res: () => void;
1030
+
1031
+ constructor(args: Omit<_SetSessionCurrentTask, "tx" | "createdAt">) {
1032
+ this.command = args;
1033
+ }
1034
+ }
1035
+
1036
+ export class DeleteSession {
1037
+ static readonly commandId = "DeleteSession" as const;
1038
+ readonly commandId = "DeleteSession" as const;
1039
+ readonly command: Omit<_DeleteSession, "tx" | "createdAt">;
1040
+ declare readonly $res: () => DeleteSessionResult;
1041
+
1042
+ constructor(args: Omit<_DeleteSession, "tx" | "createdAt">) {
1043
+ this.command = args;
1044
+ }
1045
+ }
1046
+
1047
+ export class DeleteMessageRead {
1048
+ static readonly commandId = "DeleteMessageRead" as const;
1049
+ readonly commandId = "DeleteMessageRead" as const;
1050
+ readonly command: Omit<_DeleteMessageRead, "tx" | "createdAt">;
1051
+ declare readonly $res: () => DeleteMessageReadResult;
1052
+
1053
+ constructor(args: Omit<_DeleteMessageRead, "tx" | "createdAt">) {
1054
+ this.command = args;
1055
+ }
1056
+ }
1057
+
1058
+ export class DeleteMessageReads {
1059
+ static readonly commandId = "DeleteMessageReads" as const;
1060
+ readonly commandId = "DeleteMessageReads" as const;
1061
+ readonly command: Omit<_DeleteMessageReads, "tx" | "createdAt">;
1062
+ declare readonly $res: () => DeleteMessageReadsResult;
1063
+
1064
+ constructor(args: Omit<_DeleteMessageReads, "tx" | "createdAt">) {
1065
+ this.command = args;
1066
+ }
1067
+ }
1068
+
1069
1069
  // Item constructors
1070
1070
  export const items = {
1071
- Session: (args: Session) => ({ item: args, itemType: "Session" }),
1072
- MessageRead: (args: MessageRead) => ({ item: args, itemType: "MessageRead" }),
1073
1071
  SessionNickname: (args: SessionNickname) => ({ item: args, itemType: "SessionNickname" }),
1074
1072
  RoomMember: (args: RoomMember) => ({ item: args, itemType: "RoomMember" }),
1075
1073
  Room: (args: Room) => ({ item: args, itemType: "Room" }),
1076
1074
  SandboxRun: (args: SandboxRun) => ({ item: args, itemType: "SandboxRun" }),
1077
1075
  Message: (args: Message) => ({ item: args, itemType: "Message" }),
1076
+ Session: (args: Session) => ({ item: args, itemType: "Session" }),
1077
+ MessageRead: (args: MessageRead) => ({ item: args, itemType: "MessageRead" }),
1078
1078
  };
1079
1079
 
1080
1080
  // Message events
@@ -0,0 +1,86 @@
1
+ // Inbox pulls that are acked only once their text has been shown.
2
+ //
3
+ // Acking in the same step as the read loses messages whenever the caller ends
4
+ // up not showing what it read: a per-turn pull that loses its timeout race, or
5
+ // an injection that fails. A pull here hands the messages back unacked; the
6
+ // caller commits it once the text is in the conversation, or releases it so
7
+ // the next pull hands the same messages out again.
8
+
9
+ /** Unread messages read but not yet acked. */
10
+ export interface InboxPull {
11
+ /** The rendered messages, ready to inject. */
12
+ readonly text: string;
13
+ /** Ack the messages. Call once `text` has been shown. */
14
+ commit(): Promise<void>;
15
+ /** Give the messages back unacked, so the next pull hands them out again. */
16
+ release(): void;
17
+ }
18
+
19
+ /** Where a pull reads, acks and renders messages. */
20
+ export interface InboxSource<M extends { messageId: string }> {
21
+ read(sessionId: string): Promise<M[] | null>;
22
+ ack(sessionId: string, messageIds: string[]): Promise<void>;
23
+ render(messages: M[]): string;
24
+ }
25
+
26
+ export class InboxPuller<M extends { messageId: string }> {
27
+ /** Per session, the ids an outstanding pull holds: still unread, so a read returns them. */
28
+ private readonly held = new Map<string, Set<string>>();
29
+ /** Per session, the pull in flight. Pulls run one at a time, so each sees what the last one holds. */
30
+ private readonly pulling = new Map<string, Promise<unknown>>();
31
+
32
+ private readonly source: InboxSource<M>;
33
+
34
+ constructor(source: InboxSource<M>) {
35
+ this.source = source;
36
+ }
37
+
38
+ pull(sessionId: string): Promise<InboxPull | null> {
39
+ const previous = this.pulling.get(sessionId) ?? Promise.resolve();
40
+ const next = previous.catch(() => {}).then(() => this.pullNow(sessionId));
41
+ this.pulling.set(sessionId, next);
42
+ void next.catch(() => {}).finally(() => {
43
+ if (this.pulling.get(sessionId) === next) this.pulling.delete(sessionId);
44
+ });
45
+ return next;
46
+ }
47
+
48
+ private async pullNow(sessionId: string): Promise<InboxPull | null> {
49
+ const held = this.held.get(sessionId) ?? new Set<string>();
50
+ const messages = (await this.source.read(sessionId))?.filter((m) => !held.has(m.messageId));
51
+ if (!messages?.length) return null;
52
+ // Render before holding anything, so a render that throws holds nothing.
53
+ const text = this.source.render(messages);
54
+ const ids = messages.map((m) => m.messageId);
55
+ for (const id of ids) held.add(id);
56
+ this.held.set(sessionId, held);
57
+ const settle = () => { for (const id of ids) held.delete(id); };
58
+ return {
59
+ text,
60
+ commit: async () => {
61
+ try {
62
+ await this.source.ack(sessionId, ids);
63
+ } finally {
64
+ settle();
65
+ }
66
+ },
67
+ release: settle,
68
+ };
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Pull for a turn, giving up after `timeoutMs` so the turn is never held up.
74
+ * A pull that lands after that is released, not acked: its messages were not
75
+ * shown, so they stay unread for the next turn.
76
+ */
77
+ export async function pullForTurn(pull: () => Promise<InboxPull | null>, timeoutMs: number): Promise<InboxPull | null> {
78
+ const pending = pull().catch(() => null);
79
+ let timer: ReturnType<typeof setTimeout> | undefined;
80
+ const deadline = new Promise<"late">((resolve) => { timer = setTimeout(() => resolve("late"), timeoutMs); });
81
+ const winner = await Promise.race([pending, deadline]);
82
+ clearTimeout(timer);
83
+ if (winner !== "late") return winner;
84
+ void pending.then((landed) => landed?.release());
85
+ return null;
86
+ }
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
23
  import { Type } from "typebox";
24
24
 
25
25
  import { MarshalDaemon } from "./daemon.ts";
26
+ import { pullForTurn } from "./inbox-pull.ts";
26
27
  import { resolveIdentity, type Identity } from "./identity.ts";
27
28
  import type { NotifyChannelMeta, SessionItem } from "./entities.ts";
28
29
  import { shouldRegisterPrimeAgentSession } from "./runtime.ts";
@@ -111,12 +112,19 @@ async function init(pi: ExtensionAPI) {
111
112
  );
112
113
  } else {
113
114
  // Compatibility fallback for older daemons that omit body metadata.
114
- const inbox = await daemon!.drainInbox(sid).catch(() => null);
115
- if (!inbox) return;
116
- await pi.sendMessage(
117
- { customType: "marshal-channel", content: inbox, display: true, details: meta },
118
- { deliverAs: "steer", triggerTurn: true },
119
- );
115
+ // The pull is acked only once it has been injected.
116
+ const pull = await daemon!.pullInbox(sid);
117
+ if (!pull) return;
118
+ try {
119
+ await pi.sendMessage(
120
+ { customType: "marshal-channel", content: pull.text, display: true, details: meta },
121
+ { deliverAs: "steer", triggerTurn: true },
122
+ );
123
+ } catch (e) {
124
+ pull.release();
125
+ throw e;
126
+ }
127
+ await pull.commit().catch(() => log("inbox ack failed"));
120
128
  }
121
129
  } catch (e) {
122
130
  log(`live push injection failed: ${String(e)}`);
@@ -328,19 +336,21 @@ async function init(pi: ExtensionAPI) {
328
336
  // Add marshal system prompt context (once per turn, before the LLM call).
329
337
  result.systemPrompt = event.systemPrompt + "\n\n" + SYSTEM_PROMPT_BLOCK;
330
338
 
331
- // Drain any missed inbox messages — only if the daemon is connected.
332
- if (daemon && sessionId && daemon.isConnected()) {
333
- daemon.registerSession(sessionId); // self-heal a dropped roster row
334
- const inbox = await Promise.race([
335
- daemon.drainInbox(sessionId).catch(() => null),
336
- new Promise<null>((r) => setTimeout(() => r(null), 2000)),
337
- ]);
338
- if (inbox) {
339
+ // Pull any missed inbox messages — only if the daemon is connected. The
340
+ // pull is acked only once its text goes into this turn; one that misses
341
+ // the deadline is released, so its messages stay unread for the next turn.
342
+ const d = daemon;
343
+ const sid = sessionId;
344
+ if (d && sid && d.isConnected()) {
345
+ d.registerSession(sid); // self-heal a dropped roster row
346
+ const pull = await pullForTurn(() => d.pullInbox(sid), 2000);
347
+ if (pull) {
339
348
  result.message = {
340
349
  customType: "marshal-inbox",
341
- content: inbox,
350
+ content: pull.text,
342
351
  display: true,
343
352
  };
353
+ void pull.commit().catch(() => log("inbox ack failed"));
344
354
  }
345
355
  }
346
356