@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 +1 -1
- package/src/daemon.ts +50 -16
- package/src/inbox-pull.ts +86 -0
- package/src/index.ts +25 -15
package/package.json
CHANGED
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
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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:
|
|
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
|
|