agent-coord-mcp 0.26.17 → 0.26.18
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/server.ts +2 -2
- package/src/tools/admin.ts +8 -1
- package/src/tools/away.ts +48 -4
- package/src/tools/records.ts +274 -18
- package/src/tools/rooms.ts +2 -1
- package/src/tools/shared.ts +18 -0
- package/src/tools/stall.ts +29 -1
- package/src/tools/transport.ts +12 -4
- package/src/tools/work.ts +70 -4
- package/src/tools/worktrees.ts +64 -2
- package/src/work.ts +4 -0
- package/dist/build.js +0 -113
- package/dist/build.js.map +0 -1
- package/dist/capabilities.js +0 -158
- package/dist/capabilities.js.map +0 -1
- package/dist/prefix.js +0 -64
- package/dist/prefix.js.map +0 -1
- package/dist/roles.js +0 -132
- package/dist/roles.js.map +0 -1
- package/dist/server-identity.js +0 -82
- package/dist/server-identity.js.map +0 -1
- package/dist/server.js +0 -646
- package/dist/server.js.map +0 -1
- package/dist/store.js +0 -553
- package/dist/store.js.map +0 -1
- package/dist/tools/admin.js +0 -317
- package/dist/tools/admin.js.map +0 -1
- package/dist/tools/attention.js +0 -73
- package/dist/tools/attention.js.map +0 -1
- package/dist/tools/away.js +0 -243
- package/dist/tools/away.js.map +0 -1
- package/dist/tools/board-ref.js +0 -208
- package/dist/tools/board-ref.js.map +0 -1
- package/dist/tools/event-kinds.js +0 -39
- package/dist/tools/event-kinds.js.map +0 -1
- package/dist/tools/events.js +0 -234
- package/dist/tools/events.js.map +0 -1
- package/dist/tools/index.js +0 -14
- package/dist/tools/index.js.map +0 -1
- package/dist/tools/logwatch.js +0 -85
- package/dist/tools/logwatch.js.map +0 -1
- package/dist/tools/messaging.js +0 -706
- package/dist/tools/messaging.js.map +0 -1
- package/dist/tools/record-events.js +0 -380
- package/dist/tools/record-events.js.map +0 -1
- package/dist/tools/records.js +0 -778
- package/dist/tools/records.js.map +0 -1
- package/dist/tools/registry.js +0 -500
- package/dist/tools/registry.js.map +0 -1
- package/dist/tools/render.js +0 -2
- package/dist/tools/render.js.map +0 -1
- package/dist/tools/rooms.js +0 -210
- package/dist/tools/rooms.js.map +0 -1
- package/dist/tools/rotate.js +0 -143
- package/dist/tools/rotate.js.map +0 -1
- package/dist/tools/scopes.js +0 -126
- package/dist/tools/scopes.js.map +0 -1
- package/dist/tools/shared.js +0 -86
- package/dist/tools/shared.js.map +0 -1
- package/dist/tools/stall.js +0 -387
- package/dist/tools/stall.js.map +0 -1
- package/dist/tools/transport.js +0 -1871
- package/dist/tools/transport.js.map +0 -1
- package/dist/tools/work.js +0 -319
- package/dist/tools/work.js.map +0 -1
- package/dist/tools/worktrees.js +0 -339
- package/dist/tools/worktrees.js.map +0 -1
- package/dist/typed-records.js +0 -174
- package/dist/typed-records.js.map +0 -1
- package/dist/work.js +0 -2
- package/dist/work.js.map +0 -1
package/dist/tools/messaging.js
DELETED
|
@@ -1,706 +0,0 @@
|
|
|
1
|
-
import { adjustCursors } from "./admin.js";
|
|
2
|
-
import { RECORD_AUTHORITY, isHuman, recordAuthorityFor, resolveRole, roleMatches } from "../roles.js";
|
|
3
|
-
import { TYPED_RECORD_CUTOVER_ISO, suggestRecordType, typedRecordGuidance, typedRecordMode, } from "../typed-records.js";
|
|
4
|
-
import { ARCHIVE_STATUS_FILE, ARCHIVE_INBOX_DIR, ARCHIVE_ROOMS_DIR, archiveJsonl, archiveInboxFile, archiveRoomFile } from "../store.js";
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
6
|
-
import { existsSync, watch } from "node:fs";
|
|
7
|
-
import { promises as fsp } from "node:fs";
|
|
8
|
-
import { z } from "zod";
|
|
9
|
-
import { readLog } from "./logwatch.js";
|
|
10
|
-
// The replay grammar is single-sourced in hooks/replay.mjs and shared with both
|
|
11
|
-
// pushers — see the note at the annotation site below. `hooks/` ships beside
|
|
12
|
-
// `dist/`, so this resolves the same in the built package as in source.
|
|
13
|
-
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
14
|
-
import { priorDeliveries, replayInfo } from "../../hooks/replay.mjs";
|
|
15
|
-
import { renderRecord } from "./render.js";
|
|
16
|
-
import path from "node:path";
|
|
17
|
-
import { AGENTS_FILE, DEFAULT_ROOM, INBOX_DIR, ROOM_FILE, ROOMS_DIR, STATUS_FILE, appendJsonl, cursorFile, ensureRoom, fileSize, getRooms, inboxFile, listInboxFiles, memberRooms, normalizeRoom, readJson, readJsonl, receiptFile, roomFile, stashHistory, retrieveHistory, updateJson, } from "../store.js";
|
|
18
|
-
import { sourceFile, getOffset, setOffset, MAX_WAIT_MS, isDecision, } from "./shared.js";
|
|
19
|
-
// ---------- send_message ----------
|
|
20
|
-
// Typed protocol record (Phase 8). Additive: omitting it reproduces v1
|
|
21
|
-
// behavior exactly.
|
|
22
|
-
const citationSchema = z.object({
|
|
23
|
-
kind: z.enum(["pr", "file", "commit", "url"]),
|
|
24
|
-
ref: z.string().min(1),
|
|
25
|
-
});
|
|
26
|
-
// Payloads are LOOSE objects: a v3 sender's extra keys ride through to disk
|
|
27
|
-
// untouched rather than being silently stripped. That is safe precisely
|
|
28
|
-
// because payload is nested — it is caller data, and nothing in it is ever
|
|
29
|
-
// read as a top-level Message field (which is what keeps a forged `tag` or
|
|
30
|
-
// `urgent` out; see the source-level lock in test/tier.test.mjs).
|
|
31
|
-
const summaryPayload = z.looseObject({ summary: z.string().min(1) });
|
|
32
|
-
// All five fields, or none. A `decision` carrying three of them is
|
|
33
|
-
// structurally wrong for the type it claims — and would render as a truncated
|
|
34
|
-
// packet, which is worse than no packet.
|
|
35
|
-
const decisionPayload = z.looseObject({
|
|
36
|
-
title: z.string().min(1),
|
|
37
|
-
context: z.string().min(1),
|
|
38
|
-
options: z.array(z.string().min(1)).min(1),
|
|
39
|
-
recommendation: z.string().min(1),
|
|
40
|
-
ifNoAction: z.string().min(1),
|
|
41
|
-
});
|
|
42
|
-
const verdictPayload = z.looseObject({
|
|
43
|
-
result: z.enum(["pass", "fail"]),
|
|
44
|
-
headRefOid: z.string().min(1),
|
|
45
|
-
notes: z.string().optional(),
|
|
46
|
-
});
|
|
47
|
-
// Discriminated on `type`, so an unknown type is rejected outright while a
|
|
48
|
-
// known type is checked only against its own shape. `payload` is optional on
|
|
49
|
-
// every arm: Phase 8 is additive and may not put a new required field on the
|
|
50
|
-
// wire. `cites` is optional here too — `done` needs a PR citation, but that is
|
|
51
|
-
// enforced in sendMessageTool as a plain {ok:false,error}, mirroring the
|
|
52
|
-
// identity-binding rejection, rather than as a schema throw.
|
|
53
|
-
export const messageRecordSchema = z.discriminatedUnion("type", [
|
|
54
|
-
z.object({ type: z.literal("decision"), payload: decisionPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
55
|
-
z.object({ type: z.literal("verdict"), payload: verdictPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
56
|
-
z.object({ type: z.literal("done"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
57
|
-
z.object({ type: z.literal("blocker"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
58
|
-
z.object({ type: z.literal("risk"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
59
|
-
z.object({ type: z.literal("fyi"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
60
|
-
z.object({ type: z.literal("action"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
61
|
-
z.object({ type: z.literal("go"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
62
|
-
z.object({ type: z.literal("scope"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
|
|
63
|
-
]);
|
|
64
|
-
// ---------- record authority (Phase 8 Task 4) ----------
|
|
65
|
-
// The table (RECORD_AUTHORITY, ../roles.ts) is a floor on the three types
|
|
66
|
-
// other agents ACT on; everything else is unrestricted.
|
|
67
|
-
//
|
|
68
|
-
// NOT A TRUST BOUNDARY. A role is self-declared at register/join (there is no
|
|
69
|
-
// authority issuing them), so this is a CONSISTENCY check: it stops a worker
|
|
70
|
-
// from accidentally emitting a `verdict` or countersigning its own `scope`,
|
|
71
|
-
// the same way a linter stops a typo. Anything that must actually be
|
|
72
|
-
// authenticated has to resolve identity-bound tokens (see tokens.json), never
|
|
73
|
-
// this table.
|
|
74
|
-
// Rejection shape mirrors the identity-binding rejection in server.ts: the
|
|
75
|
-
// caller gets a plain `{ok: false, error}`, and nothing is written.
|
|
76
|
-
export async function checkRecordAuthority(from, record) {
|
|
77
|
-
if (!record)
|
|
78
|
-
return { ok: true };
|
|
79
|
-
const rule = RECORD_AUTHORITY[record.type];
|
|
80
|
-
if (!rule)
|
|
81
|
-
return { ok: true };
|
|
82
|
-
const reg = await readJson(AGENTS_FILE, {});
|
|
83
|
-
const entry = reg[from];
|
|
84
|
-
if (roleMatches(entry, rule.roles))
|
|
85
|
-
return { ok: true };
|
|
86
|
-
const held = resolveRole(entry);
|
|
87
|
-
return {
|
|
88
|
-
ok: false,
|
|
89
|
-
error: `record.type '${record.type}' is restricted to ${rule.label} roles ` +
|
|
90
|
-
`(${[...rule.roles].join(", ")}); sender '${from}' holds ` +
|
|
91
|
-
`${held ? `role '${held.roleId}'` : entry ? "no role" : "no registry entry"}. ` +
|
|
92
|
-
`Send it as text, or register with the role that owns this record type.`,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
// ---------- typed records obligatory (Phase 5.1 Task 12) ----------
|
|
96
|
-
// An untyped agent→agent message must not be able to EXIST. Enforced HERE, at
|
|
97
|
-
// the send, and not at the render: a rule applied where the message is read
|
|
98
|
-
// leaves the untyped message on disk, and the next reader re-derives the type
|
|
99
|
-
// from prose. See src/typed-records.ts for the staging, the suggestion rules,
|
|
100
|
-
// and why `fyi` stays an honest catch-all.
|
|
101
|
-
//
|
|
102
|
-
// Returns `undefined` when the send is fine, a WARNING string while the rule is
|
|
103
|
-
// staged, or a REFUSAL after the cutover.
|
|
104
|
-
//
|
|
105
|
-
// TWO EXEMPTIONS, AND THEY ARE DIFFERENT IN KIND:
|
|
106
|
-
// - the RECIPIENT is a human. Canon: "David-facing messages may use normal
|
|
107
|
-
// prose". Scoped to agent→agent traffic, so the one channel whose reader is
|
|
108
|
-
// a person is untouched. Nothing is declared for this — it is a property of
|
|
109
|
-
// who is being written to.
|
|
110
|
-
// - the SENDER holds a prose-only exemption, declared per agent at join and
|
|
111
|
-
// visible in list_agents. That one is a statement about a model's ability
|
|
112
|
-
// to pick a type, and it is paid for by every reader.
|
|
113
|
-
async function typedRecordCheck(args) {
|
|
114
|
-
if (args.record?.type)
|
|
115
|
-
return { ok: true };
|
|
116
|
-
const reg = await readJson(AGENTS_FILE, {});
|
|
117
|
-
// David-facing prose stays prose. An UNREGISTERED recipient is treated as an
|
|
118
|
-
// agent, not as a human: the safe reading of "I cannot tell" is the rule, and
|
|
119
|
-
// a human on this bus has a registry entry (that is how the pane is found).
|
|
120
|
-
if (args.to && isHuman(reg[args.to]))
|
|
121
|
-
return { ok: true };
|
|
122
|
-
const sender = reg[args.from];
|
|
123
|
-
if (sender?.proseOnly)
|
|
124
|
-
return { ok: true };
|
|
125
|
-
const suggestion = suggestRecordType(args.text, recordAuthorityFor(sender).mayNotEmit);
|
|
126
|
-
const guidance = typedRecordGuidance(suggestion);
|
|
127
|
-
if (typedRecordMode() === "warn") {
|
|
128
|
-
return {
|
|
129
|
-
ok: true,
|
|
130
|
-
warning: `UNTYPED — stored, but this send is REFUSED from ${TYPED_RECORD_CUTOVER_ISO}. ` +
|
|
131
|
-
`${guidance} Until then an untyped multi-line message arrives in full in every reader's context ` +
|
|
132
|
-
`instead of one line plus a retrieve_message handle. ` +
|
|
133
|
-
`A model that cannot pick a type declares proseOnly:true at join (per-agent, visible in list_agents).`,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
return {
|
|
137
|
-
ok: false,
|
|
138
|
-
error: `agent→agent messages must carry a typed record (since ${TYPED_RECORD_CUTOVER_ISO}) — nothing was written. ` +
|
|
139
|
-
`${guidance} ` +
|
|
140
|
-
`Types: decision · verdict · done · blocker · risk · fyi · action · go · scope; 'fyi' is the honest ` +
|
|
141
|
-
`catch-all — do not force a false 'decision'/'risk' to get past this. ` +
|
|
142
|
-
`Messages TO a human are exempt, and an agent that cannot pick a type declares proseOnly:true at join.`,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
export const sendMessageSchema = {
|
|
146
|
-
from: z.string().min(1),
|
|
147
|
-
to: z.string().optional(),
|
|
148
|
-
room: z.string().optional(),
|
|
149
|
-
// Optional ONLY so a record can fill it (Task 3.3). This relaxes a
|
|
150
|
-
// constraint rather than adding one, so v1 senders are unaffected; a call
|
|
151
|
-
// with neither `text` nor a renderable `record` is rejected in the tool.
|
|
152
|
-
text: z.string().min(1).optional(),
|
|
153
|
-
kind: z.enum(["decision", "status", "chatter"]).optional(),
|
|
154
|
-
record: messageRecordSchema.optional(),
|
|
155
|
-
// Additive reply link. Validated in the tool (malformed → {ok:false};
|
|
156
|
-
// unknown → store + warn) so the rejection shape matches identity-binding
|
|
157
|
-
// / unknown-recipient, not a schema throw. Existing records unchanged.
|
|
158
|
-
inReplyTo: z.string().optional(),
|
|
159
|
-
};
|
|
160
|
-
const MESSAGE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
161
|
-
async function messageIdExists(id) {
|
|
162
|
-
const files = [];
|
|
163
|
-
for (const n of await listInboxFiles())
|
|
164
|
-
files.push(path.join(INBOX_DIR, n));
|
|
165
|
-
files.push(ROOM_FILE);
|
|
166
|
-
if (existsSync(ROOMS_DIR)) {
|
|
167
|
-
for (const n of await fsp.readdir(ROOMS_DIR)) {
|
|
168
|
-
if (n.endsWith(".jsonl"))
|
|
169
|
-
files.push(path.join(ROOMS_DIR, n));
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
if (existsSync(ARCHIVE_INBOX_DIR)) {
|
|
173
|
-
for (const n of await fsp.readdir(ARCHIVE_INBOX_DIR)) {
|
|
174
|
-
if (n.endsWith(".jsonl"))
|
|
175
|
-
files.push(path.join(ARCHIVE_INBOX_DIR, n));
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
if (existsSync(ARCHIVE_ROOMS_DIR)) {
|
|
179
|
-
for (const n of await fsp.readdir(ARCHIVE_ROOMS_DIR)) {
|
|
180
|
-
if (n.endsWith(".jsonl"))
|
|
181
|
-
files.push(path.join(ARCHIVE_ROOMS_DIR, n));
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
for (const f of files) {
|
|
185
|
-
const entries = await readJsonl(f);
|
|
186
|
-
if (entries.some((m) => m.id === id))
|
|
187
|
-
return true;
|
|
188
|
-
}
|
|
189
|
-
return false;
|
|
190
|
-
}
|
|
191
|
-
// q-314e0187: `process.pid` is this MCP process's own pid — the process
|
|
192
|
-
// writing the entry right now, always known, no lookup needed. It is what a
|
|
193
|
-
// disputed message can be cross-referenced against (sessions/*.json,
|
|
194
|
-
// `ps -p <pid>`) when two live sessions hold one identity. In stdio mode this
|
|
195
|
-
// pid uniquely identifies the session (one process per session); in HTTP mode
|
|
196
|
-
// many sessions can share a process, so it identifies the SERVER handling the
|
|
197
|
-
// call rather than the caller — still strictly more than nothing, and the
|
|
198
|
-
// same scoping `trackSession` already applies to session markers.
|
|
199
|
-
function messageProvenance() {
|
|
200
|
-
return {
|
|
201
|
-
pid: process.pid,
|
|
202
|
-
...(process.env.TMUX_PANE ? { tmuxPane: process.env.TMUX_PANE } : {}),
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
export async function sendMessageTool(args) {
|
|
206
|
-
// Record authority first — a sender who may not emit this type is refused
|
|
207
|
-
// before any other check runs, so a rejected record writes nothing anywhere.
|
|
208
|
-
const authority = await checkRecordAuthority(args.from, args.record);
|
|
209
|
-
if (!authority.ok)
|
|
210
|
-
return { ok: false, error: authority.error };
|
|
211
|
-
// A `done` must cite the work it claims. Presence and shape only — resolving
|
|
212
|
-
// the ref against gh/git is a consumer's job, and the send path makes no
|
|
213
|
-
// network calls. Rejected as a value, not a throw, mirroring the
|
|
214
|
-
// identity-binding rejection in src/server.ts.
|
|
215
|
-
if (args.record?.type === "done") {
|
|
216
|
-
const hasPr = (args.record.cites ?? []).some((c) => c.kind === "pr" && c.ref.trim().length > 0);
|
|
217
|
-
if (!hasPr) {
|
|
218
|
-
return {
|
|
219
|
-
ok: false,
|
|
220
|
-
error: "a 'done' record must carry at least one {kind:'pr'} citation — an uncited DONE is an unverifiable claim",
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
// `text` is what every consumer reads, so it must exist. The author's
|
|
225
|
-
// wording ALWAYS wins: a record renders only to fill an absent text, never
|
|
226
|
-
// to overwrite one.
|
|
227
|
-
const text = args.text ?? (args.record ? renderRecord(args.record) : null);
|
|
228
|
-
if (!text) {
|
|
229
|
-
return {
|
|
230
|
-
ok: false,
|
|
231
|
-
error: args.record
|
|
232
|
-
? `record type '${args.record.type}' has no payload to render — supply 'text', or a payload the type's layout can render`
|
|
233
|
-
: "'text' is required when no record is supplied",
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
// After `text` is resolved (a record can fill it) and before anything is
|
|
237
|
-
// written, so a refusal leaves nothing on disk.
|
|
238
|
-
const typed = await typedRecordCheck({ from: args.from, to: args.to, text, record: args.record });
|
|
239
|
-
if (!typed.ok)
|
|
240
|
-
return { ok: false, error: typed.error };
|
|
241
|
-
const typedWarning = typed.warning;
|
|
242
|
-
let replyWarning;
|
|
243
|
-
if (args.inReplyTo !== undefined) {
|
|
244
|
-
if (!MESSAGE_ID_RE.test(args.inReplyTo)) {
|
|
245
|
-
return {
|
|
246
|
-
ok: false,
|
|
247
|
-
error: `inReplyTo '${args.inReplyTo}' is not a message id (expected uuid)`,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
if (!(await messageIdExists(args.inReplyTo))) {
|
|
251
|
-
replyWarning =
|
|
252
|
-
`inReplyTo '${args.inReplyTo}' is not a known message — reply stored but no matching parent may exist`;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
// The tmux pusher's injection guard (hooks/tmux-pusher.mjs `shouldInject`)
|
|
256
|
-
// never types leading-slash text into a pane — only `send_command`'s
|
|
257
|
-
// allowlist goes in raw. The message is still stored and readable, so say so
|
|
258
|
-
// here instead of letting the sender assume it landed on screen.
|
|
259
|
-
const slashWarning = /^\s*\//.test(text)
|
|
260
|
-
? "text starts with '/' — stored, but tmux-push transports never type leading-slash text into a pane (injection guard); prefix it (e.g. `AGENT_ACTION: run /<skill> …`) or use `send_command` for /clear and /compact"
|
|
261
|
-
: undefined;
|
|
262
|
-
// DM → inbox. Otherwise resolve the channel (default `general`), make sure it
|
|
263
|
-
// exists in the registry, and tag the message with its channel.
|
|
264
|
-
if (args.to) {
|
|
265
|
-
// A ROOM NAME IN `to:` IS REFUSED, AND THE GUARD SITS ABOVE THE WRITE.
|
|
266
|
-
//
|
|
267
|
-
// `to: "<room>"` used to append the message to `inbox/<room>.jsonl` — a file
|
|
268
|
-
// nothing reads — and return `ok: true`. A coordinator's CANON post landed
|
|
269
|
-
// nowhere and it took a stranded file on disk to find it. The ordering IS the
|
|
270
|
-
// defect: the append ran BEFORE the registry lookup, so a refusal added after
|
|
271
|
-
// the existing check would still have left the message written.
|
|
272
|
-
//
|
|
273
|
-
// REFUSE rather than warn-and-store, and the distinction is not fussiness: a
|
|
274
|
-
// typo'd agent id is a plausible mistake worth a warning, because the id might
|
|
275
|
-
// become real. A room name is a CATEGORY ERROR the server can identify with
|
|
276
|
-
// certainty — rooms and agents are different namespaces and the confusion is
|
|
277
|
-
// one keystroke. Measured when this shipped: zero overlap between room names
|
|
278
|
-
// and registered agent ids, so a refusal cannot misfire on a real recipient.
|
|
279
|
-
const rooms = await getRooms();
|
|
280
|
-
if (Object.prototype.hasOwnProperty.call(rooms, normalizeRoom(args.to))) {
|
|
281
|
-
return {
|
|
282
|
-
ok: false,
|
|
283
|
-
error: `'${args.to}' is a ROOM, not an agent — nothing was written. ` +
|
|
284
|
-
`A DM to a room name lands in an inbox no one reads and would have returned ok. ` +
|
|
285
|
-
`Post to the channel with room:'${normalizeRoom(args.to)}' (omit 'to'), or name an agent id.`,
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
const msg = {
|
|
289
|
-
id: randomUUID(),
|
|
290
|
-
ts: Date.now(),
|
|
291
|
-
from: args.from,
|
|
292
|
-
to: args.to,
|
|
293
|
-
text,
|
|
294
|
-
...(args.record ? { record: args.record } : {}),
|
|
295
|
-
...(args.inReplyTo ? { inReplyTo: args.inReplyTo } : {}),
|
|
296
|
-
provenance: messageProvenance(),
|
|
297
|
-
};
|
|
298
|
-
const target = inboxFile(args.to);
|
|
299
|
-
await appendJsonl(target, msg);
|
|
300
|
-
// Offline delivery is intentional (the inbox is created on demand), but a
|
|
301
|
-
// typo'd recipient shouldn't vanish silently — surface a warning when the
|
|
302
|
-
// target isn't a known agent so the caller can catch the mistake.
|
|
303
|
-
const reg = await readJson(AGENTS_FILE, {});
|
|
304
|
-
const recipientWarning = reg[args.to]
|
|
305
|
-
? undefined
|
|
306
|
-
: `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
|
|
307
|
-
const warning = [typedWarning, recipientWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
|
|
308
|
-
return { ok: true, id: msg.id, target, room: undefined, warning };
|
|
309
|
-
}
|
|
310
|
-
const chan = normalizeRoom(args.room);
|
|
311
|
-
if (chan !== DEFAULT_ROOM)
|
|
312
|
-
await ensureRoom(chan, args.from);
|
|
313
|
-
const msg = {
|
|
314
|
-
id: randomUUID(),
|
|
315
|
-
ts: Date.now(),
|
|
316
|
-
from: args.from,
|
|
317
|
-
room: chan,
|
|
318
|
-
text,
|
|
319
|
-
...(args.kind ? { kind: args.kind } : {}),
|
|
320
|
-
...(args.record ? { record: args.record } : {}),
|
|
321
|
-
...(args.inReplyTo ? { inReplyTo: args.inReplyTo } : {}),
|
|
322
|
-
provenance: messageProvenance(),
|
|
323
|
-
};
|
|
324
|
-
const target = roomFile(chan);
|
|
325
|
-
await appendJsonl(target, msg);
|
|
326
|
-
await maybeCompactRoom(chan);
|
|
327
|
-
const roomWarning = [typedWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
|
|
328
|
-
return { ok: true, id: msg.id, target, room: chan, ...(roomWarning ? { warning: roomWarning } : {}) };
|
|
329
|
-
}
|
|
330
|
-
// ---------- live compaction (self-limiting streams) ----------
|
|
331
|
-
// Rooms and the status stream compact themselves on write: once a live file
|
|
332
|
-
// grows past its threshold, the oldest entries move to the archive (never
|
|
333
|
-
// deleted) and every cursor shifts down. Fresh decisions (< decisionDays old)
|
|
334
|
-
// are exempt — they stay in the live file. Cursor adjustment subtracts the
|
|
335
|
-
// full removed count, so an agent parked behind a kept decision may re-read
|
|
336
|
-
// it once; at-least-once beats silently skipping.
|
|
337
|
-
function envInt(name, fallback) {
|
|
338
|
-
const n = Number(process.env[name]);
|
|
339
|
-
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
340
|
-
}
|
|
341
|
-
const ROOM_COMPACT_THRESHOLD = envInt("AGENT_COORD_ROOM_MAX", 1000);
|
|
342
|
-
const ROOM_COMPACT_KEEP = envInt("AGENT_COORD_ROOM_KEEP", 500);
|
|
343
|
-
const STATUS_COMPACT_THRESHOLD = envInt("AGENT_COORD_STATUS_MAX", 2000);
|
|
344
|
-
const STATUS_COMPACT_KEEP = envInt("AGENT_COORD_STATUS_KEEP", 1000);
|
|
345
|
-
// Skip the entry count entirely while the file is small — the common case on
|
|
346
|
-
// every send. ~150 bytes/entry means the threshold can't be hit below this.
|
|
347
|
-
const COMPACT_SIZE_GATE = 100 * 1024;
|
|
348
|
-
const DECISION_FRESH_MS = 30 * 24 * 60 * 60 * 1000;
|
|
349
|
-
async function maybeCompactRoom(chan) {
|
|
350
|
-
const file = roomFile(chan);
|
|
351
|
-
if ((await fileSize(file)) < COMPACT_SIZE_GATE)
|
|
352
|
-
return;
|
|
353
|
-
const entries = await readJsonl(file);
|
|
354
|
-
if (entries.length <= ROOM_COMPACT_THRESHOLD)
|
|
355
|
-
return;
|
|
356
|
-
const boundaryTs = entries[entries.length - ROOM_COMPACT_KEEP].ts;
|
|
357
|
-
const decisionCutoff = Date.now() - DECISION_FRESH_MS;
|
|
358
|
-
const r = await archiveJsonl(file, archiveRoomFile(chan), (e) => e.ts >= boundaryTs || (isDecision(e) && e.ts > decisionCutoff));
|
|
359
|
-
if (r.removed > 0)
|
|
360
|
-
await adjustCursors({ roomRemovedByChan: { [chan]: r.removed } });
|
|
361
|
-
}
|
|
362
|
-
async function maybeCompactStatus() {
|
|
363
|
-
if ((await fileSize(STATUS_FILE)) < COMPACT_SIZE_GATE)
|
|
364
|
-
return;
|
|
365
|
-
const entries = await readJsonl(STATUS_FILE);
|
|
366
|
-
if (entries.length <= STATUS_COMPACT_THRESHOLD)
|
|
367
|
-
return;
|
|
368
|
-
const boundaryTs = entries[entries.length - STATUS_COMPACT_KEEP].ts;
|
|
369
|
-
const r = await archiveJsonl(STATUS_FILE, ARCHIVE_STATUS_FILE, (e) => e.ts >= boundaryTs);
|
|
370
|
-
if (r.removed > 0)
|
|
371
|
-
await adjustCursors({ statusRemoved: r.removed });
|
|
372
|
-
}
|
|
373
|
-
// ---------- read_messages ----------
|
|
374
|
-
export const readMessagesSchema = {
|
|
375
|
-
agentId: z.string().min(1),
|
|
376
|
-
source: z.enum(["inbox", "room", "status"]),
|
|
377
|
-
room: z.string().optional(),
|
|
378
|
-
limit: z.number().int().positive().max(500).optional(),
|
|
379
|
-
peek: z.boolean().optional(),
|
|
380
|
-
sinceTs: z.number().optional(),
|
|
381
|
-
};
|
|
382
|
-
export async function readMessagesTool(args) {
|
|
383
|
-
const file = sourceFile(args.source, args.agentId, args.room);
|
|
384
|
-
const all = await readJsonl(file);
|
|
385
|
-
let entries = [];
|
|
386
|
-
let totalNew = 0;
|
|
387
|
-
// Room and status reads default to 50 entries to prevent agents flooding
|
|
388
|
-
// themselves with full history on join — the status stream grows unbounded
|
|
389
|
-
// across the fleet. Inbox drains fully since it is targeted by nature.
|
|
390
|
-
const effectiveLimit = args.limit ?? (args.source === "inbox" ? undefined : 50);
|
|
391
|
-
if (args.peek) {
|
|
392
|
-
const cursor = await readJson(cursorFile(args.agentId), {});
|
|
393
|
-
const startOffset = getOffset(cursor, args.source, args.room);
|
|
394
|
-
entries = all.slice(startOffset);
|
|
395
|
-
if (args.sinceTs !== undefined)
|
|
396
|
-
entries = entries.filter((e) => e.ts > args.sinceTs);
|
|
397
|
-
totalNew = entries.length;
|
|
398
|
-
}
|
|
399
|
-
else {
|
|
400
|
-
await updateJson(cursorFile(args.agentId), {}, (current) => {
|
|
401
|
-
const startOffset = getOffset(current, args.source, args.room);
|
|
402
|
-
let e = all.slice(startOffset);
|
|
403
|
-
if (args.sinceTs !== undefined)
|
|
404
|
-
e = e.filter((x) => x.ts > args.sinceTs);
|
|
405
|
-
totalNew = e.length;
|
|
406
|
-
entries = e;
|
|
407
|
-
// Advance past EVERYTHING we account for here (recent window + any
|
|
408
|
-
// overflow we stash below). The overflow is recoverable via the history
|
|
409
|
-
// hash, so it must not requeue for the next read — that would re-flood.
|
|
410
|
-
if (e.length > 0)
|
|
411
|
-
setOffset(current, args.source, args.room, startOffset + e.length);
|
|
412
|
-
return current;
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
// CCR overflow handling (room and status sources). When the backlog exceeds
|
|
416
|
-
// the window, return the RECENT slice raw and replace the older overflow with
|
|
417
|
-
// a compact digest carrying a retrieval hash. The agent expands it on demand
|
|
418
|
-
// via retrieve_room_history. Peek is side-effect-free, so it never stashes —
|
|
419
|
-
// it reports the count and tells the agent to do a real read to get a hash.
|
|
420
|
-
let recent = entries;
|
|
421
|
-
let history;
|
|
422
|
-
if (args.source !== "inbox" && effectiveLimit && entries.length > effectiveLimit) {
|
|
423
|
-
const overflow = entries.slice(0, entries.length - effectiveLimit);
|
|
424
|
-
recent = entries.slice(entries.length - effectiveLimit);
|
|
425
|
-
const stashKey = args.source === "room" ? normalizeRoom(args.room) : "status";
|
|
426
|
-
if (args.peek) {
|
|
427
|
-
history = { digest: digestOverflow(overflow, undefined), older: overflow.length };
|
|
428
|
-
}
|
|
429
|
-
else {
|
|
430
|
-
const hash = await stashHistory(stashKey, args.agentId, overflow);
|
|
431
|
-
history = { digest: digestOverflow(overflow, hash), hash, older: overflow.length };
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
else if (effectiveLimit && entries.length > effectiveLimit) {
|
|
435
|
-
// Inbox keeps the legacy oldest-first chunking (no stash) — targeted
|
|
436
|
-
// messages must never be skipped over.
|
|
437
|
-
recent = entries.slice(0, effectiveLimit);
|
|
438
|
-
}
|
|
439
|
-
// Drop the agent's own posts on shared channels — reading your own broadcast
|
|
440
|
-
// back is never useful and confuses turn-based agents into self-replies.
|
|
441
|
-
// Cursor has already advanced past them, so they won't reappear.
|
|
442
|
-
const visible = args.source === "room" || args.source === "status"
|
|
443
|
-
? recent.filter((e) => entryAuthor(e) !== args.agentId)
|
|
444
|
-
: recent;
|
|
445
|
-
// Phase 5.3 Task 21.2 — ANNOTATE A MESSAGE THAT HAS ALREADY BEEN DELIVERED.
|
|
446
|
-
//
|
|
447
|
-
// The REMOTE pusher (scripts/coord-pusher.mjs) consumes the bus over the wire
|
|
448
|
-
// and cannot read this host's receipts/, so it cannot see for itself that a
|
|
449
|
-
// message it is about to paste was pasted before. Without this, the replay
|
|
450
|
-
// marker would appear in local panes only — HALF THE FLEET, and the half a
|
|
451
|
-
// reader could not identify from inside the pane, which is worse than no
|
|
452
|
-
// marker because its absence would read as "live".
|
|
453
|
-
//
|
|
454
|
-
// The two routes compute the same DATA and share ONE renderer
|
|
455
|
-
// (hooks/replay.mjs `replayMarker`), so neither pane can be handed a
|
|
456
|
-
// different vocabulary for the same fact.
|
|
457
|
-
const priorText = await fsp.readFile(receiptFile(args.agentId), "utf8").catch(() => "");
|
|
458
|
-
const prior = priorDeliveries(priorText);
|
|
459
|
-
const annotated = visible.map((e) => {
|
|
460
|
-
const replay = "id" in e ? replayInfo(prior, e.id) : undefined;
|
|
461
|
-
return replay ? { ...e, replay } : e;
|
|
462
|
-
});
|
|
463
|
-
return {
|
|
464
|
-
ok: true,
|
|
465
|
-
messages: annotated,
|
|
466
|
-
totalNew,
|
|
467
|
-
returned: annotated.length,
|
|
468
|
-
room: args.source === "room" ? normalizeRoom(args.room) : undefined,
|
|
469
|
-
...(history ? { history } : {}),
|
|
470
|
-
};
|
|
471
|
-
}
|
|
472
|
-
// Lossless summary of a stashed backlog slice: surfaces error/failure posts
|
|
473
|
-
// verbatim (the lines that usually matter most in a flood) and collapses the
|
|
474
|
-
// rest to counts. Mirrors headroom's content-aware digest, kept deliberately
|
|
475
|
-
// simple — the full originals are one retrieve_room_history call away.
|
|
476
|
-
function digestOverflow(over, hash) {
|
|
477
|
-
const authors = new Set(over.map(entryAuthor).filter(Boolean));
|
|
478
|
-
const errorRe = /\b(error|fatal|fail(ed|ure)?|panic|exception)\b/i;
|
|
479
|
-
const errors = over.filter((m) => errorRe.test(JSON.stringify(m)));
|
|
480
|
-
const first = over[0]?.ts;
|
|
481
|
-
const last = over[over.length - 1]?.ts;
|
|
482
|
-
const span = first && last && last > first ? ` over ${Math.round((last - first) / 60000)}m` : "";
|
|
483
|
-
const parts = [
|
|
484
|
-
`[${over.length} earlier message${over.length === 1 ? "" : "s"} compressed`,
|
|
485
|
-
`${authors.size} agent${authors.size === 1 ? "" : "s"}${span}`,
|
|
486
|
-
];
|
|
487
|
-
if (errors.length)
|
|
488
|
-
parts.push(`${errors.length} error post${errors.length === 1 ? "" : "s"}`);
|
|
489
|
-
const head = parts.join(", ");
|
|
490
|
-
const tail = hash
|
|
491
|
-
? ` hash=${hash}] — call retrieve_room_history(hash="${hash}") to expand`
|
|
492
|
-
: `] — read without peek to get an expandable hash`;
|
|
493
|
-
// Decisions are the one thing a digest must not bury — quote them verbatim
|
|
494
|
-
// (capped) below the summary line.
|
|
495
|
-
const decisions = over.filter((m) => isDecision(m));
|
|
496
|
-
const quoted = decisions
|
|
497
|
-
.slice(-5)
|
|
498
|
-
.map((d) => ` [decision] ${d.from}: ${d.text.length > 200 ? d.text.slice(0, 200) + "…" : d.text}`);
|
|
499
|
-
const decisionBlock = decisions.length
|
|
500
|
-
? `\n${quoted.join("\n")}${decisions.length > 5 ? `\n (+${decisions.length - 5} earlier decisions in hash)` : ""}`
|
|
501
|
-
: "";
|
|
502
|
-
return head + tail + decisionBlock;
|
|
503
|
-
}
|
|
504
|
-
// ---------- retrieve_message (Phase 8 Task 6) ----------
|
|
505
|
-
export const retrieveMessageSchema = {
|
|
506
|
-
agentId: z.string().min(1),
|
|
507
|
-
id: z.string().min(1),
|
|
508
|
-
};
|
|
509
|
-
// Expand a digest handle back into the full typed record.
|
|
510
|
-
//
|
|
511
|
-
// NOT a cache. The handle is the message's own `id`, and this reads the source
|
|
512
|
-
// of truth — rooms/<chan>.jsonl or inbox/<agent>.jsonl. That matters for two
|
|
513
|
-
// reasons the CCR history cache could not offer: nothing is duplicated, and
|
|
514
|
-
// nothing expires. A stashed copy would have carried HISTORY_TTL_MS and become
|
|
515
|
-
// permanently unrecoverable after 30 minutes, which is exactly when a handle
|
|
516
|
-
// that outlived a /clear would be expanded.
|
|
517
|
-
//
|
|
518
|
-
// It also sidesteps the cursor: the pusher SHARES the cursor file with
|
|
519
|
-
// read_messages, so anything already delivered to a pane is behind the cursor
|
|
520
|
-
// and re-reading the channel would not return it. A by-id lookup never
|
|
521
|
-
// consults a cursor.
|
|
522
|
-
//
|
|
523
|
-
// AUTHORITY BY CONSTRUCTION: we only ever open files this agent is entitled to
|
|
524
|
-
// read — its own inbox, and the rooms it is a member of. There is no separate
|
|
525
|
-
// permission check that could disagree with the search, and a handle for a
|
|
526
|
-
// message delivered somewhere else simply is not found. That is the same
|
|
527
|
-
// property the history cache spent `forAgent` scoping to get.
|
|
528
|
-
export async function retrieveMessageTool(args) {
|
|
529
|
-
const rooms = await memberRooms(args.agentId);
|
|
530
|
-
const scopes = [
|
|
531
|
-
{
|
|
532
|
-
source: "inbox",
|
|
533
|
-
room: undefined,
|
|
534
|
-
live: inboxFile(args.agentId),
|
|
535
|
-
archived: archiveInboxFile(args.agentId),
|
|
536
|
-
},
|
|
537
|
-
...rooms.map((r) => ({
|
|
538
|
-
source: "room",
|
|
539
|
-
room: r,
|
|
540
|
-
live: roomFile(r),
|
|
541
|
-
archived: archiveRoomFile(r),
|
|
542
|
-
})),
|
|
543
|
-
];
|
|
544
|
-
// Live files first. The archive is opened ONLY on a miss, so no normal
|
|
545
|
-
// retrieval touches it and compaction/prune semantics are unchanged — but a
|
|
546
|
-
// record that compaction moved is still recoverable, because archive/ is
|
|
547
|
-
// append-only and complete.
|
|
548
|
-
for (const pass of ["live", "archived"]) {
|
|
549
|
-
for (const s of scopes) {
|
|
550
|
-
const entries = await readJsonl(s[pass]);
|
|
551
|
-
const msg = entries.find((m) => m.id === args.id);
|
|
552
|
-
if (msg) {
|
|
553
|
-
return {
|
|
554
|
-
ok: true,
|
|
555
|
-
id: msg.id,
|
|
556
|
-
source: s.source,
|
|
557
|
-
room: s.room,
|
|
558
|
-
archived: pass === "archived",
|
|
559
|
-
message: msg,
|
|
560
|
-
record: msg.record,
|
|
561
|
-
};
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
return {
|
|
566
|
-
ok: false,
|
|
567
|
-
error: `no message '${args.id}' in any channel you can read — it may never have been delivered to you`,
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
|
-
// ---------- retrieve_room_history ----------
|
|
571
|
-
export const retrieveRoomHistorySchema = {
|
|
572
|
-
agentId: z.string().min(1),
|
|
573
|
-
hash: z.string().min(1),
|
|
574
|
-
query: z.string().optional(),
|
|
575
|
-
};
|
|
576
|
-
export async function retrieveRoomHistoryTool(args) {
|
|
577
|
-
const res = await retrieveHistory(args.hash, args.agentId, args.query);
|
|
578
|
-
if (!res.ok) {
|
|
579
|
-
const reason = res.reason === "expired"
|
|
580
|
-
? "That history entry has expired (30m TTL). Re-read the channel with a higher limit to fetch it again."
|
|
581
|
-
: res.reason === "forbidden"
|
|
582
|
-
? "That history hash was produced for a different agent and cannot be retrieved by you."
|
|
583
|
-
: "No history entry for that hash. It may have expired or never existed.";
|
|
584
|
-
return { ok: false, reason: res.reason, message: reason };
|
|
585
|
-
}
|
|
586
|
-
return {
|
|
587
|
-
ok: true,
|
|
588
|
-
room: res.room,
|
|
589
|
-
hash: args.hash,
|
|
590
|
-
total: res.total,
|
|
591
|
-
returned: res.messages.length,
|
|
592
|
-
messages: res.messages,
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
function entryAuthor(e) {
|
|
596
|
-
return "from" in e ? e.from : e.agentId;
|
|
597
|
-
}
|
|
598
|
-
// ---------- post_status ----------
|
|
599
|
-
export const postStatusSchema = {
|
|
600
|
-
agentId: z.string().min(1),
|
|
601
|
-
status: z.string().min(1),
|
|
602
|
-
detail: z.string().optional(),
|
|
603
|
-
};
|
|
604
|
-
export async function postStatusTool(args) {
|
|
605
|
-
const entry = {
|
|
606
|
-
id: randomUUID(),
|
|
607
|
-
ts: Date.now(),
|
|
608
|
-
agentId: args.agentId,
|
|
609
|
-
status: args.status,
|
|
610
|
-
detail: args.detail,
|
|
611
|
-
};
|
|
612
|
-
await appendJsonl(STATUS_FILE, entry);
|
|
613
|
-
await maybeCompactStatus();
|
|
614
|
-
return { ok: true, id: entry.id };
|
|
615
|
-
}
|
|
616
|
-
// ---------- wait_for_message ----------
|
|
617
|
-
export const waitForMessageSchema = {
|
|
618
|
-
agentId: z.string().min(1),
|
|
619
|
-
source: z.enum(["inbox", "room", "status"]),
|
|
620
|
-
room: z.string().optional(),
|
|
621
|
-
timeoutMs: z.number().int().positive().max(MAX_WAIT_MS).optional(),
|
|
622
|
-
};
|
|
623
|
-
export async function waitForMessageTool(args) {
|
|
624
|
-
const totalTimeout = Math.min(args.timeoutMs ?? 30_000, MAX_WAIT_MS);
|
|
625
|
-
const file = sourceFile(args.source, args.agentId, args.room);
|
|
626
|
-
const deadline = Date.now() + totalTimeout;
|
|
627
|
-
// Loop so that file growth caused only by the agent's own self-posts (which
|
|
628
|
-
// readMessagesTool now filters out for room/status) doesn't return an empty
|
|
629
|
-
// result — keep waiting until we have something to deliver or time out.
|
|
630
|
-
while (Date.now() < deadline) {
|
|
631
|
-
// ALREADY-UNREAD CONTENT WAKES THIS IMMEDIATELY.
|
|
632
|
-
//
|
|
633
|
-
// The loop below waits for the file to GROW from the size captured at entry,
|
|
634
|
-
// so anything that arrived before the wait started could not wake it: the
|
|
635
|
-
// agent sat through the full timeout with messages readable the whole time.
|
|
636
|
-
// Seven real posts landed across consecutive 60s waits that all returned
|
|
637
|
-
// `timedOut`, while `read_messages` returned every one.
|
|
638
|
-
//
|
|
639
|
-
// Growth is the wrong question on its own — "is there anything for me" is
|
|
640
|
-
// the right one, and it is also the cheaper check.
|
|
641
|
-
const pending = await readMessagesTool({ agentId: args.agentId, source: args.source, room: args.room });
|
|
642
|
-
if (pending.messages.length > 0)
|
|
643
|
-
return { ...pending, waited: true, timedOut: false };
|
|
644
|
-
// 8.4 — THE SAME POSITION THE WATCH USES, not a second mechanism beside it.
|
|
645
|
-
//
|
|
646
|
-
// This compared byte SIZE, which answers "did the file grow" — a proxy for
|
|
647
|
-
// "is there a new line". The watch keys on LINE OFFSET, which is the thing
|
|
648
|
-
// itself, and reusing it means the wait and the watch cannot disagree about
|
|
649
|
-
// what "new" means. A second mechanism beside the first is how two answers
|
|
650
|
-
// to one question start drifting.
|
|
651
|
-
//
|
|
652
|
-
// It still consumes nothing by itself: the offset is read, never written
|
|
653
|
-
// back to the cursor (8.2).
|
|
654
|
-
const startLines = readLog(file).total;
|
|
655
|
-
const startSize = await fileSize(file);
|
|
656
|
-
const remaining = deadline - Date.now();
|
|
657
|
-
if (remaining <= 0)
|
|
658
|
-
break;
|
|
659
|
-
const changed = await new Promise((resolve) => {
|
|
660
|
-
let settled = false;
|
|
661
|
-
const finish = (v) => {
|
|
662
|
-
if (settled)
|
|
663
|
-
return;
|
|
664
|
-
settled = true;
|
|
665
|
-
clearInterval(poll);
|
|
666
|
-
try {
|
|
667
|
-
watcher?.close();
|
|
668
|
-
}
|
|
669
|
-
catch {
|
|
670
|
-
// ignore
|
|
671
|
-
}
|
|
672
|
-
clearTimeout(t);
|
|
673
|
-
resolve(v);
|
|
674
|
-
};
|
|
675
|
-
const check = async () => {
|
|
676
|
-
// Line count first — a write that changes bytes without adding a line
|
|
677
|
-
// (a rewrite, a truncation) is not new traffic, and size alone reports
|
|
678
|
-
// it as such.
|
|
679
|
-
if (readLog(file).total > startLines)
|
|
680
|
-
return finish(true);
|
|
681
|
-
const sz = await fileSize(file);
|
|
682
|
-
if (sz > startSize)
|
|
683
|
-
finish(true);
|
|
684
|
-
};
|
|
685
|
-
let watcher;
|
|
686
|
-
try {
|
|
687
|
-
watcher = watch(file, () => {
|
|
688
|
-
void check();
|
|
689
|
-
});
|
|
690
|
-
}
|
|
691
|
-
catch {
|
|
692
|
-
// file may not exist; polling will handle
|
|
693
|
-
}
|
|
694
|
-
const poll = setInterval(() => void check(), 500);
|
|
695
|
-
const t = setTimeout(() => finish(false), remaining);
|
|
696
|
-
});
|
|
697
|
-
if (!changed)
|
|
698
|
-
break;
|
|
699
|
-
const result = await readMessagesTool({ agentId: args.agentId, source: args.source, room: args.room });
|
|
700
|
-
if (result.returned > 0)
|
|
701
|
-
return result;
|
|
702
|
-
// otherwise, only self-posts arrived; keep waiting on the remaining budget
|
|
703
|
-
}
|
|
704
|
-
return { ok: false, timedOut: true };
|
|
705
|
-
}
|
|
706
|
-
//# sourceMappingURL=messaging.js.map
|