@agent-marshal/marshal-prime-agent 0.54.0 → 0.55.0

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.0"
53
+ "version": "0.55.0"
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
  }
@@ -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