@sema-agent/core 7.2.0 → 7.3.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/agents/cross-session-envelope.d.ts +7 -0
  3. package/dist/agents/cross-session-envelope.js +4 -0
  4. package/dist/agents/list-agents-tool.d.ts +55 -0
  5. package/dist/agents/list-agents-tool.js +94 -0
  6. package/dist/agents/peer-admission.d.ts +17 -1
  7. package/dist/agents/peer-admission.js +19 -2
  8. package/dist/agents/peer-directory.d.ts +208 -0
  9. package/dist/agents/peer-directory.js +272 -0
  10. package/dist/agents/peer-session-drain.d.ts +159 -0
  11. package/dist/agents/peer-session-drain.js +245 -0
  12. package/dist/agents/send-message-tool.d.ts +31 -0
  13. package/dist/agents/send-message-tool.js +145 -4
  14. package/dist/agents/subagent-steps.d.ts +11 -0
  15. package/dist/agents/subagent-steps.js +27 -4
  16. package/dist/brain/status-sink.d.ts +10 -0
  17. package/dist/brain/status-sink.js +13 -4
  18. package/dist/brain/stream-engine.d.ts +11 -0
  19. package/dist/brain/stream-engine.js +39 -3
  20. package/dist/core/arg-summary.d.ts +13 -3
  21. package/dist/core/arg-summary.js +138 -7
  22. package/dist/core/auto-mode-arming.d.ts +11 -0
  23. package/dist/core/auto-mode-arming.js +7 -1
  24. package/dist/core/auto-mode-prompt.d.ts +5 -0
  25. package/dist/core/auto-mode-prompt.js +2 -1
  26. package/dist/core/auto-mode-rebuild.d.ts +2 -1
  27. package/dist/core/auto-mode-rebuild.js +2 -0
  28. package/dist/core/checkpoint-store.d.ts +14 -0
  29. package/dist/core/checkpoint-store.js +4 -3
  30. package/dist/core/governance-codes.d.ts +1 -1
  31. package/dist/core/governance-codes.js +6 -0
  32. package/dist/core/mailbox-store.d.ts +89 -2
  33. package/dist/core/mailbox-store.js +77 -2
  34. package/dist/core/permission-rule-model.d.ts +9 -0
  35. package/dist/core/permission-rule-model.js +4 -1
  36. package/dist/core/runner/prepare-task.d.ts +20 -0
  37. package/dist/core/runner/prepare-task.js +152 -37
  38. package/dist/core/runner/runtask.js +5 -2
  39. package/dist/core/runner/tool-output-projection.js +1 -0
  40. package/dist/core/store-contracts/mailbox-store-contract.d.ts +23 -0
  41. package/dist/core/store-contracts/mailbox-store-contract.js +157 -1
  42. package/dist/core/task-notification.d.ts +38 -9
  43. package/dist/core/task-notification.js +8 -2
  44. package/dist/core/tools.js +1 -0
  45. package/dist/core/types.d.ts +176 -26
  46. package/dist/core/wiring-manifest.d.ts +62 -5
  47. package/dist/core/wiring-manifest.js +9 -0
  48. package/dist/engine/harness/agent-harness.d.ts +1 -0
  49. package/dist/engine/harness/agent-harness.js +3 -0
  50. package/dist/engine/harness/types.d.ts +3 -0
  51. package/dist/engine/loop/agent-loop.d.ts +7 -0
  52. package/dist/engine/loop/agent-loop.js +79 -0
  53. package/dist/engine/loop/types.d.ts +42 -0
  54. package/dist/index.d.ts +12 -5
  55. package/dist/index.js +11 -4
  56. package/dist/internal/harness-types.d.ts +1 -1
  57. package/dist/stores/cc/mailbox-store.d.ts +1 -1
  58. package/dist/stores/cc/mailbox-store.js +13 -0
  59. package/dist/stores/file/adoption/marker.d.ts +1 -1
  60. package/dist/stores/file/mailbox-store.d.ts +57 -0
  61. package/dist/stores/file/mailbox-store.js +369 -18
  62. package/dist/tools/fs/fs-write.js +69 -3
  63. package/package.json +1 -1
  64. package/test/export-surface.snapshot.json +121 -1
@@ -0,0 +1,272 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { isAbsolute, dirname, resolve as resolvePath } from "node:path";
3
+ import { formatPeerNameRef, mintPeerRef, normalizePeerName, parsePeerNameRef, PEER_REF_RE } from "./cross-session-ref.js";
4
+ import { inlineUntrusted } from "../core/untrusted-text.js";
5
+ export const PEER_SESSION_RECORD_SCHEMA_VERSION = 1;
6
+ export const PEER_SESSION_RECORD_MAX_BYTES = 262_144;
7
+ export const SESSION_BOX_PREFIX = "session.";
8
+ const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
9
+ export const PEER_SESSION_ID_GRAMMAR = "^[A-Za-z0-9_-]{1,80}$";
10
+ export function isPeerSessionId(sessionId) {
11
+ return SESSION_ID_RE.test(sessionId);
12
+ }
13
+ const SESSION_ADDRESS_RE = /^session\.([A-Za-z0-9_-]{1,80})$/i;
14
+ export function peerSessionBoxHandle(sessionId) {
15
+ if (!SESSION_ID_RE.test(sessionId))
16
+ throw new Error(`peerSessionBoxHandle: session id ${JSON.stringify(sessionId)} is outside the address grammar ^[A-Za-z0-9_-]{1,80}$`);
17
+ return `${SESSION_BOX_PREFIX}${sessionId.toLowerCase()}`;
18
+ }
19
+ export function parsePeerSessionAddress(text) {
20
+ const m = SESSION_ADDRESS_RE.exec(text.trim());
21
+ return m === null ? undefined : m[1].toLowerCase();
22
+ }
23
+ function optNumber(v) {
24
+ return v === undefined || (typeof v === "number" && Number.isFinite(v));
25
+ }
26
+ function optString(v) {
27
+ return v === undefined || typeof v === "string";
28
+ }
29
+ function optStringArray(v) {
30
+ return v === undefined || (Array.isArray(v) && v.every((x) => typeof x === "string"));
31
+ }
32
+ export function readPeerSessionRecord(raw) {
33
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
34
+ return { ok: false, reason: "not_an_object" };
35
+ const r = raw;
36
+ if (typeof r["schemaVersion"] !== "number" || !Number.isInteger(r["schemaVersion"]) || r["schemaVersion"] < 1)
37
+ return { ok: false, reason: "schema_missing" };
38
+ if (r["schemaVersion"] > PEER_SESSION_RECORD_SCHEMA_VERSION)
39
+ return { ok: false, reason: "schema_newer" };
40
+ if (typeof r["sessionId"] !== "string" || !SESSION_ID_RE.test(r["sessionId"]))
41
+ return { ok: false, reason: "session_id_invalid" };
42
+ if (typeof r["scope"] !== "string" || r["scope"] === "")
43
+ return { ok: false, reason: "scope_invalid" };
44
+ if (typeof r["name"] !== "string" || normalizePeerName(r["name"]) === "")
45
+ return { ok: false, reason: "name_invalid" };
46
+ const liveness = r["liveness"];
47
+ if (liveness !== "live" && liveness !== "dead" && liveness !== "deleted")
48
+ return { ok: false, reason: "liveness_invalid" };
49
+ const bad = (field) => ({ ok: false, reason: "field_invalid", field });
50
+ if (!optNumber(r["startedAt"]) || r["startedAt"] === undefined)
51
+ return bad("startedAt");
52
+ if (!optNumber(r["updatedAt"]) || r["updatedAt"] === undefined)
53
+ return bad("updatedAt");
54
+ if (!optNumber(r["pid"]))
55
+ return bad("pid");
56
+ if (!optNumber(r["procStartMs"]))
57
+ return bad("procStartMs");
58
+ if (!optNumber(r["diedAt"]))
59
+ return bad("diedAt");
60
+ if (!optNumber(r["peerProtocol"]))
61
+ return bad("peerProtocol");
62
+ if (!optString(r["cwd"]))
63
+ return bad("cwd");
64
+ if (!optString(r["entrypoint"]))
65
+ return bad("entrypoint");
66
+ if (!optString(r["sockPath"]))
67
+ return bad("sockPath");
68
+ if (!optStringArray(r["formerNames"]))
69
+ return bad("formerNames");
70
+ if (!optStringArray(r["peerFeatures"]))
71
+ return bad("peerFeatures");
72
+ const nameSource = r["nameSource"];
73
+ if (nameSource !== undefined && nameSource !== "auto" && nameSource !== "flag" && nameSource !== "rename")
74
+ return bad("nameSource");
75
+ const tempo = r["tempo"];
76
+ if (tempo !== undefined && tempo !== "active" && tempo !== "idle" && tempo !== "blocked")
77
+ return bad("tempo");
78
+ const inboundPosture = r["inboundPosture"];
79
+ if (inboundPosture !== undefined && inboundPosture !== "available" && inboundPosture !== "unavailable")
80
+ return bad("inboundPosture");
81
+ const record = {
82
+ schemaVersion: r["schemaVersion"],
83
+ sessionId: r["sessionId"],
84
+ scope: r["scope"],
85
+ name: r["name"],
86
+ ...(nameSource !== undefined ? { nameSource } : {}),
87
+ ...(r["formerNames"] !== undefined ? { formerNames: [...r["formerNames"]] } : {}),
88
+ ...(r["pid"] !== undefined ? { pid: r["pid"] } : {}),
89
+ ...(r["procStartMs"] !== undefined ? { procStartMs: r["procStartMs"] } : {}),
90
+ ...(r["cwd"] !== undefined ? { cwd: r["cwd"] } : {}),
91
+ startedAt: r["startedAt"],
92
+ updatedAt: r["updatedAt"],
93
+ liveness,
94
+ ...(r["diedAt"] !== undefined ? { diedAt: r["diedAt"] } : {}),
95
+ ...(tempo !== undefined ? { tempo } : {}),
96
+ ...(r["entrypoint"] !== undefined ? { entrypoint: r["entrypoint"] } : {}),
97
+ ...(r["peerProtocol"] !== undefined ? { peerProtocol: r["peerProtocol"] } : {}),
98
+ ...(r["peerFeatures"] !== undefined ? { peerFeatures: [...r["peerFeatures"]] } : {}),
99
+ ...(r["sockPath"] !== undefined ? { sockPath: r["sockPath"] } : {}),
100
+ ...(inboundPosture !== undefined ? { inboundPosture } : {}),
101
+ };
102
+ return { ok: true, record };
103
+ }
104
+ export const defaultPeerLivenessProbe = {
105
+ processExists(pid) {
106
+ try {
107
+ process.kill(pid, 0);
108
+ return true;
109
+ }
110
+ catch (e) {
111
+ return e.code === "EPERM";
112
+ }
113
+ },
114
+ };
115
+ export function isPeerSessionProcessAlive(record, probe = defaultPeerLivenessProbe) {
116
+ if (record.liveness !== "live")
117
+ return false;
118
+ if (record.pid === undefined)
119
+ return true;
120
+ if (!probe.processExists(record.pid))
121
+ return false;
122
+ if (record.procStartMs !== undefined && probe.processStartMs !== undefined) {
123
+ const started = probe.processStartMs(record.pid);
124
+ if (started !== undefined && Math.abs(started - record.procStartMs) > 2_000)
125
+ return false;
126
+ }
127
+ return true;
128
+ }
129
+ export function mintPeerSessionCandidates(records, self) {
130
+ const entries = records.map((record) => ({ kind: "session", id: record.sessionId.toLowerCase(), record }));
131
+ const selfEntry = self !== undefined && !records.some((r) => r.sessionId.toLowerCase() === self.sessionId.toLowerCase()) ? { kind: "session", id: self.sessionId.toLowerCase() } : undefined;
132
+ return mintPeerRef(entries, selfEntry).map((e) => ({ record: e.record, ref: e.ref }));
133
+ }
134
+ function describeCandidate(c) {
135
+ const state = c.record.liveness === "live" ? "" : c.record.liveness === "dead" ? " (offline)" : " (deleted)";
136
+ return `${formatPeerNameRef(inlineUntrusted(c.record.name, 64), c.ref)}${state} → ${peerSessionBoxHandle(c.record.sessionId)}`;
137
+ }
138
+ export function resolvePeerSessions(records, to, self) {
139
+ const candidates = mintPeerSessionCandidates(records, self);
140
+ const selfId = self?.sessionId.toLowerCase();
141
+ const settle = (c, rung) => {
142
+ if (selfId !== undefined && c.record.sessionId.toLowerCase() === selfId)
143
+ return { status: "self" };
144
+ if (c.record.liveness === "deleted")
145
+ return { status: "deleted", candidate: c };
146
+ return { status: "found", candidate: c, rung };
147
+ };
148
+ const address = parsePeerSessionAddress(to);
149
+ if (address !== undefined) {
150
+ if (selfId !== undefined && address === selfId)
151
+ return { status: "self" };
152
+ const hit = candidates.find((c) => c.record.sessionId.toLowerCase() === address);
153
+ return hit === undefined ? { status: "not_found" } : settle(hit, "address");
154
+ }
155
+ const nameRef = parsePeerNameRef(to);
156
+ const rawName = nameRef?.name ?? to;
157
+ const key = normalizePeerName(rawName);
158
+ if (key === "")
159
+ return { status: "not_found" };
160
+ const byName = candidates.filter((c) => normalizePeerName(c.record.name) === key);
161
+ if (nameRef !== undefined) {
162
+ const hit = byName.find((c) => c.ref === nameRef.ref) ?? (PEER_REF_RE.test(nameRef.ref) ? candidates.find((c) => c.ref === nameRef.ref && normalizePeerName(c.record.name) === key) : undefined);
163
+ if (hit === undefined)
164
+ return { status: "stale_ref", ref: nameRef.ref };
165
+ return settle(hit, "ref");
166
+ }
167
+ if (byName.length === 0)
168
+ return { status: "not_found" };
169
+ if (byName.length === 1)
170
+ return settle(byName[0], "name");
171
+ const sorted = [...byName].sort((a, b) => (a.record.liveness === b.record.liveness ? b.record.updatedAt - a.record.updatedAt : a.record.liveness === "live" ? -1 : 1));
172
+ return {
173
+ status: "ambiguous",
174
+ candidates: sorted,
175
+ message: `"${inlineUntrusted(rawName, 64)}" matches ${sorted.length} sessions — re-send with the name and ref of the one you mean: ${sorted.map(describeCandidate).join("; ")}`,
176
+ };
177
+ }
178
+ export function createInMemoryPeerDirectory() {
179
+ const rows = new Map();
180
+ const key = (scope, sessionId) => JSON.stringify([scope, sessionId.toLowerCase()]);
181
+ return {
182
+ listPeerSessions(access) {
183
+ return [...rows.values()].filter((r) => r.scope === access.scope).map((r) => ({ ...r }));
184
+ },
185
+ upsert(record) {
186
+ const read = readPeerSessionRecord(record);
187
+ if (!read.ok)
188
+ throw new Error(`InMemoryPeerDirectory.upsert: refused row (${read.reason}${read.field !== undefined ? `: ${read.field}` : ""})`);
189
+ rows.set(key(record.scope, record.sessionId), read.record);
190
+ },
191
+ markDead(scope, sessionId, diedAt = Date.now()) {
192
+ const r = rows.get(key(scope, sessionId));
193
+ if (r === undefined || r.liveness !== "live")
194
+ return false;
195
+ rows.set(key(scope, sessionId), { ...r, liveness: "dead", diedAt, updatedAt: diedAt });
196
+ return true;
197
+ },
198
+ markDeleted(scope, sessionId, at = Date.now()) {
199
+ const r = rows.get(key(scope, sessionId));
200
+ if (r === undefined)
201
+ return false;
202
+ rows.set(key(scope, sessionId), { ...r, liveness: "deleted", diedAt: r.diedAt ?? at, updatedAt: at });
203
+ return true;
204
+ },
205
+ restorePeerSession(record) {
206
+ const k = key(record.scope, record.sessionId);
207
+ if (rows.has(k))
208
+ return false;
209
+ const read = readPeerSessionRecord(record);
210
+ if (!read.ok)
211
+ return false;
212
+ rows.set(k, read.record);
213
+ return true;
214
+ },
215
+ async sweep(scope, boxEmpty) {
216
+ const swept = [];
217
+ for (const [k, r] of [...rows.entries()]) {
218
+ if (r.scope !== scope || r.liveness === "live")
219
+ continue;
220
+ if (await boxEmpty(r.sessionId)) {
221
+ if (rows.get(k) !== r)
222
+ continue;
223
+ rows.delete(k);
224
+ swept.push(r.sessionId);
225
+ }
226
+ }
227
+ return swept;
228
+ },
229
+ };
230
+ }
231
+ export async function vetPeerRegistryDirectory(dir, opts) {
232
+ if (!isAbsolute(dir))
233
+ return { ok: false, code: "not_absolute", path: dir };
234
+ const uid = opts?.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined);
235
+ const stopAt = opts?.stopAt !== undefined ? resolvePath(opts.stopAt) : undefined;
236
+ let cursor = resolvePath(dir);
237
+ for (;;) {
238
+ let st;
239
+ try {
240
+ st = await lstat(cursor);
241
+ }
242
+ catch {
243
+ return { ok: false, code: "missing", path: cursor };
244
+ }
245
+ if (st.isSymbolicLink())
246
+ return { ok: false, code: "symlink", path: cursor };
247
+ if (!st.isDirectory())
248
+ return { ok: false, code: "not_directory", path: cursor };
249
+ const parent = dirname(cursor);
250
+ const isRoot = parent === cursor || (stopAt !== undefined && cursor === stopAt);
251
+ if (!isRoot) {
252
+ if (uid !== undefined && st.uid !== uid)
253
+ return { ok: false, code: "foreign_owner", path: cursor };
254
+ if ((st.mode & 0o022) !== 0)
255
+ return { ok: false, code: "group_or_world_writable", path: cursor };
256
+ }
257
+ if (isRoot)
258
+ return { ok: true };
259
+ cursor = parent;
260
+ }
261
+ }
262
+ export function judgePeerRecordFile(stat, selfUid) {
263
+ if (!stat.isFile)
264
+ return { ok: false, code: "not_regular" };
265
+ if (selfUid !== undefined && stat.uid !== undefined && stat.uid !== selfUid)
266
+ return { ok: false, code: "foreign_owner" };
267
+ if ((stat.mode & 0o022) !== 0)
268
+ return { ok: false, code: "group_or_world_writable" };
269
+ if (stat.size > PEER_SESSION_RECORD_MAX_BYTES)
270
+ return { ok: false, code: "too_large" };
271
+ return { ok: true };
272
+ }
@@ -0,0 +1,159 @@
1
+ import { type MailboxCrossProcessVerdict, type MailboxStore } from "../core/mailbox-store.js";
2
+ import type { SystemInjectionPriority, TaskNotificationPayload } from "../core/task-notification.js";
3
+ import { type EngineNotice, type RunnerDeps } from "../core/types.js";
4
+ import { type PermissionModeClass } from "./cross-session-envelope.js";
5
+ import { type CrossSessionInboundSettingLayers } from "./cross-session-judge.js";
6
+ import { type PeerAdmissionConfig } from "./peer-admission.js";
7
+ import { type PeerDirectory } from "./peer-directory.js";
8
+ import { createListAgentsTool } from "./list-agents-tool.js";
9
+ import type { AskEffective } from "../core/wiring-manifest.js";
10
+ import type { SendMessageToolOptions } from "./send-message-tool.js";
11
+ /** design/385 §1.2④ — the drain lease TTL. Same sizing law as the tier-3 revive lease: well under the
12
+ * store's stale-row floor, long enough to cover one turn (a longer turn renews on the same owner at
13
+ * its next boundary; an expired lease re-serves the batch — at-least-once). */
14
+ export declare const PEER_DRAIN_LEASE_TTL_MS: number;
15
+ /** The lane-mount refusal for a run whose own session id cannot be spelled as a peer address. */
16
+ export declare const PEER_SESSION_ID_UNGRAMMATICAL_CODE = "peer.session_id_ungrammatical";
17
+ /** The lane's mount verdict: the mailbox declaration's verdict, or this leg's own address refusal. */
18
+ export type PeerLaneMountVerdict = MailboxCrossProcessVerdict | {
19
+ ok: false;
20
+ code: typeof PEER_SESSION_ID_UNGRAMMATICAL_CODE;
21
+ reason: string;
22
+ };
23
+ /**
24
+ * design/385 §1.2⑥ / §5.1 — may the cross-session lane mount on this deployment? `undefined` = no
25
+ * directory seat (no lane, nothing to say). A directory with no mailbox, or over a mailbox that does not
26
+ * declare cross-process safety, is REFUSED with a named reason (bad-value loudness: several terminals
27
+ * sharing one box on luck is exactly what the declaration exists to forbid). With a `sessionId`, the
28
+ * leg's OWN address is judged too: a host-minted session id outside the peer address grammar has no
29
+ * session box to drain and no address a peer could send to, so the lane refuses by name — the
30
+ * alternative was `peerSessionBoxHandle` throwing out of prepare and failing the whole run over an
31
+ * optional feature.
32
+ */
33
+ export declare function judgePeerLaneMount(deps: {
34
+ peerDirectory?: PeerDirectory;
35
+ mailboxStore?: MailboxStore;
36
+ }, sessionId?: string): PeerLaneMountVerdict | undefined;
37
+ export interface PeerSessionDrainOptions {
38
+ mailbox: MailboxStore;
39
+ /** This session (the box owner) and this leg's cycle id (the lease owner). */
40
+ sessionId: string;
41
+ runId: string;
42
+ /** The registry/mailbox scope this run mounts in. */
43
+ scope: string;
44
+ /** The run's own notification injector (the task-notification lane; `next` tier). */
45
+ inject: (notification: TaskNotificationPayload, opts?: {
46
+ priority?: SystemInjectionPriority;
47
+ }) => void;
48
+ /** Deployment tuning of the admission gate (the same seat SendMessage reads). */
49
+ admission?: Partial<PeerAdmissionConfig>;
50
+ /** The recipient's self-token set for the hop check, read live (the run's peer identity ref). */
51
+ ownTokens: () => readonly string[];
52
+ /** The recipient's `crossSessionInbound` layers, read at every drain. */
53
+ settingLayers: () => CrossSessionInboundSettingLayers | undefined;
54
+ /** The recipient's own permission-mode class, read at every drain (the manifest's ask fold). */
55
+ selfModeClass: () => PermissionModeClass | "unknown";
56
+ onNotice?: (notice: EngineNotice) => void;
57
+ onError?: (error: unknown, ctx: {
58
+ phase: "degraded";
59
+ sessionId: string;
60
+ classification: string;
61
+ }) => void;
62
+ leaseTtlMs?: number;
63
+ }
64
+ export interface PeerSessionDrain {
65
+ /** One drain round (serialized: a boundary never overlaps a still-running round). */
66
+ drain(): Promise<void>;
67
+ /** Run end: ack the last batch and release the lease so the session's next cycle claims at once. */
68
+ finish(): Promise<void>;
69
+ }
70
+ export declare function createPeerSessionDrain(opts: PeerSessionDrainOptions): PeerSessionDrain;
71
+ /** The lane's late-bound facts: the ask fold resolves after the manifest derivation, the mount runs
72
+ * before it — both read this one cell. */
73
+ export interface PeerLaneRefs {
74
+ askEffective?: AskEffective;
75
+ }
76
+ /**
77
+ * The mount verdict + its loud half: `true` = the lane mounts on this leg; `false` = no seat, or the
78
+ * seat was refused — the refusal is announced ONCE per leg as `config.peer_lane_unmounted` naming the
79
+ * reason (never mounted on luck, never silently inert).
80
+ */
81
+ export declare function announcePeerLaneMount(deps: {
82
+ peerDirectory?: PeerDirectory;
83
+ mailboxStore?: MailboxStore;
84
+ onNotice?: (n: EngineNotice) => void;
85
+ }, leg: {
86
+ sessionId: string;
87
+ runId: string;
88
+ }): boolean;
89
+ /** The SendMessage seats of the lane (design/385 §2): the directory (address arm + last name rung) and
90
+ * this session's identity/attestation, whose mode class folds LIVE off the manifest's ask derivation. */
91
+ export declare function peerLaneSendMessageSeats(args: {
92
+ peerDirectory: PeerDirectory;
93
+ sessionId: string;
94
+ scope: string;
95
+ name?: string;
96
+ refs: PeerLaneRefs;
97
+ listingMounted?: boolean;
98
+ }): Pick<SendMessageToolOptions, "peerDirectory" | "peerSelfSession" | "peerListingMounted">;
99
+ /**
100
+ * design/385 §5.1 — THE ListAgents mount predicate, single source: the built-in mounts only when the
101
+ * face does not exclude it and no caller tool owns either spelling (`ListAgents` / `ListPeers`) as its
102
+ * name OR as an alias. Every reader of "is ListAgents on this roster?" — the mount itself and the
103
+ * SendMessage face's listing phrase — calls this one predicate, so a face can never name a tool the
104
+ * roster does not carry (a second, inline copy of the rule drifted on exactly the alias arms).
105
+ */
106
+ export declare function listAgentsMountable(face: {
107
+ exclude: readonly string[] | undefined;
108
+ specTools: ReadonlyArray<{
109
+ name: string;
110
+ aliases?: readonly string[];
111
+ }>;
112
+ }): boolean;
113
+ /** design/385 §5.1 — the ListAgents mount (alias ListPeers): read-only, concurrency-safe, on the peer
114
+ * seat's own arm beside SendMessage; a caller-supplied tool of the same name wins (skip, not throw). */
115
+ export declare function mountListAgents(args: {
116
+ peerDirectory: PeerDirectory;
117
+ sessionId: string;
118
+ scope: string;
119
+ hostTaskId: string;
120
+ /** The delegated child's trusted parent pair (RunInternals), when this run is a child. */
121
+ parentTaskId?: string;
122
+ parentSessionId?: string;
123
+ registry: ListAgentsToolRegistry;
124
+ roster?: NonNullable<Parameters<typeof createListAgentsTool>[0]["roster"]>;
125
+ specTools: ReadonlyArray<{
126
+ name: string;
127
+ aliases?: readonly string[];
128
+ }>;
129
+ toolEffects: Map<string, "read" | "write" | "idempotent">;
130
+ }): ReturnType<typeof createListAgentsTool> | undefined;
131
+ type ListAgentsToolRegistry = NonNullable<Parameters<typeof createListAgentsTool>[0]["registry"]>;
132
+ /** The harness surface the binding needs (structural — the machine itself never sees the harness). */
133
+ export interface PeerDrainHarness {
134
+ on(type: "turn_boundary" | "before_agent_start", handler: () => Promise<undefined>): unknown;
135
+ subscribe(listener: (event: {
136
+ type: string;
137
+ }) => void | Promise<void>): unknown;
138
+ }
139
+ /**
140
+ * Bind the drain to a leg: the run's own injector at the `next` tier, run open (the loop's first
141
+ * steering drain follows the `before_agent_start` hook) + every turn boundary, and the run-end
142
+ * ack/release on `agent_end`.
143
+ */
144
+ export declare function bindPeerSessionDrain(harness: PeerDrainHarness, opts: PeerSessionDrainOptions): PeerSessionDrain;
145
+ /**
146
+ * The leg-side spelling of {@link bindPeerSessionDrain}: the deployment seats the drain reads
147
+ * (`mailboxStore` / `peerAdmission` / `crossSessionInbound` / the two sinks) come straight off deps, the
148
+ * recipient's mode class folds off the lane refs — one binding call per leg, no per-seat plumbing.
149
+ */
150
+ export declare function bindPeerLaneDrain(harness: PeerDrainHarness, args: {
151
+ deps: Pick<RunnerDeps, "mailboxStore" | "peerAdmission" | "crossSessionInbound" | "onNotice" | "onError">;
152
+ sessionId: string;
153
+ runId: string;
154
+ scope: string;
155
+ inject: PeerSessionDrainOptions["inject"];
156
+ ownTokens: () => readonly string[];
157
+ refs: PeerLaneRefs;
158
+ }): PeerSessionDrain;
159
+ export {};
@@ -0,0 +1,245 @@
1
+ import { mailboxCrossProcessMountVerdict } from "../core/mailbox-store.js";
2
+ import { deliverEngineNotice } from "../core/types.js";
3
+ import { buildCrossSessionEnvelope, CrossSessionCodecError, encodePeerAddress } from "./cross-session-envelope.js";
4
+ import { judgeCrossSessionInbound, resolveCrossSessionInboundSetting } from "./cross-session-judge.js";
5
+ import { judgePeerAdmission, peerAxisToken, resolvePeerAdmissionConfig } from "./peer-admission.js";
6
+ import { isPeerSessionId, PEER_SESSION_ID_GRAMMAR, peerSessionBoxHandle } from "./peer-directory.js";
7
+ import { inlineUntrusted } from "../core/untrusted-text.js";
8
+ import { createListAgentsTool, LIST_AGENTS_TOOL_ALIAS, LIST_AGENTS_TOOL_NAME } from "./list-agents-tool.js";
9
+ import { foldPermissionModeClass } from "./cross-session-judge.js";
10
+ export const PEER_DRAIN_LEASE_TTL_MS = 5 * 60_000;
11
+ export const PEER_SESSION_ID_UNGRAMMATICAL_CODE = "peer.session_id_ungrammatical";
12
+ export function judgePeerLaneMount(deps, sessionId) {
13
+ if (deps.peerDirectory === undefined)
14
+ return undefined;
15
+ if (deps.mailboxStore === undefined) {
16
+ return { ok: false, code: "mailbox.cross_process_unsafe", reason: "RunnerDeps.peerDirectory is wired but RunnerDeps.mailboxStore is not — the cross-session lane parks messages in the recipient's durable session box and has no store to park them in" };
17
+ }
18
+ const store = mailboxCrossProcessMountVerdict(deps.mailboxStore);
19
+ if (!store.ok)
20
+ return store;
21
+ if (sessionId !== undefined && !isPeerSessionId(sessionId)) {
22
+ return {
23
+ ok: false,
24
+ code: PEER_SESSION_ID_UNGRAMMATICAL_CODE,
25
+ reason: `this run's session id ${JSON.stringify(inlineUntrusted(sessionId, 96))} is outside the peer address grammar ${PEER_SESSION_ID_GRAMMAR} — it has no session box to drain and no address a peer could send to (mint session ids the grammar accepts to use the lane)`,
26
+ };
27
+ }
28
+ return { ok: true };
29
+ }
30
+ export function createPeerSessionDrain(opts) {
31
+ const { mailbox, sessionId, runId, scope } = opts;
32
+ const boxHandle = peerSessionBoxHandle(sessionId);
33
+ const drainOwner = `drain:${runId}`;
34
+ const recipientKey = peerAxisToken(scope, "s", sessionId);
35
+ const ttl = opts.leaseTtlMs ?? PEER_DRAIN_LEASE_TTL_MS;
36
+ let settledUpTo;
37
+ const disclosedHeld = new Set();
38
+ let inFlight = Promise.resolve();
39
+ const disclose = (disposition, cause, m, heldNote = "the held-message review face is not mounted in this build") => deliverEngineNotice(opts.onNotice, {
40
+ code: "peer.inbound_disposition",
41
+ message: `cross-session message seq ${m.seq} in this session's box was ${disposition === "held" ? `HELD (it stays parked; ${heldNote})` : disposition === "refused" ? "refused by this session's crossSessionInbound setting" : disposition === "notice_unrouted" ? "settled WITHOUT delivery (a notice-kind record; the notice face is not mounted in this build)" : "refused by the inbound admission gate"}: ${cause}`,
42
+ detail: { disposition, cause, seq: m.seq, box: boxHandle, ...(m.peerMeta?.fromSession !== undefined ? { fromSession: m.peerMeta.fromSession } : {}), sessionId, runId },
43
+ });
44
+ const ackSettled = async () => {
45
+ if (settledUpTo === undefined)
46
+ return;
47
+ const upTo = settledUpTo;
48
+ settledUpTo = undefined;
49
+ try {
50
+ await mailbox.ack(scope, boxHandle, drainOwner, upTo);
51
+ }
52
+ catch (e) {
53
+ opts.onError?.(e, { phase: "degraded", sessionId, classification: "peer-box-ack" });
54
+ }
55
+ };
56
+ const drainOnce = async () => {
57
+ let lease;
58
+ try {
59
+ lease = await mailbox.claimLease(scope, boxHandle, drainOwner, ttl);
60
+ }
61
+ catch (e) {
62
+ opts.onError?.(e, { phase: "degraded", sessionId, classification: "peer-box-claim" });
63
+ return;
64
+ }
65
+ const previouslySettled = settledUpTo;
66
+ await ackSettled();
67
+ if (lease === null)
68
+ return;
69
+ let setting;
70
+ let admissionConfig;
71
+ let selfModeClass;
72
+ try {
73
+ setting = resolveCrossSessionInboundSetting(opts.settingLayers() ?? {});
74
+ admissionConfig = resolvePeerAdmissionConfig(opts.admission);
75
+ selfModeClass = opts.selfModeClass();
76
+ }
77
+ catch (e) {
78
+ opts.onError?.(e, { phase: "degraded", sessionId, classification: "peer-box-settings" });
79
+ return;
80
+ }
81
+ for (const m of lease.messages) {
82
+ if (previouslySettled !== undefined && m.seq <= previouslySettled)
83
+ continue;
84
+ const meta = m.peerMeta;
85
+ if (m.hopChain !== undefined) {
86
+ const verdict = judgePeerAdmission(scope, recipientKey, { senderKey: meta?.senderKey, body: m.content, prospectiveChain: m.hopChain, ownTokens: opts.ownTokens(), seq: m.seq }, admissionConfig, undefined, "drain");
87
+ if (!verdict.ok) {
88
+ if (verdict.reason === "rate_limited") {
89
+ if (!disclosedHeld.has(m.seq)) {
90
+ disclosedHeld.add(m.seq);
91
+ disclose("held", verdict.reason, m, "the inbound admission bucket is empty; it is re-judged at a later boundary once the bucket refills");
92
+ }
93
+ break;
94
+ }
95
+ disclose("admission_refused", verdict.reason, m);
96
+ settledUpTo = m.seq;
97
+ continue;
98
+ }
99
+ }
100
+ if (meta?.kind !== undefined && meta.kind !== "peer_message") {
101
+ disclose("notice_unrouted", `kind=${meta.kind}`, m);
102
+ settledUpTo = m.seq;
103
+ continue;
104
+ }
105
+ {
106
+ const judged = judgeCrossSessionInbound({ setting, selfModeClass, sender: { ...(meta?.fromMode !== undefined ? { fromMode: meta.fromMode } : {}), selfSent: meta?.fromSession !== undefined && meta.fromSession.toLowerCase() === sessionId.toLowerCase() } });
107
+ if (judged.verdict === "refuse") {
108
+ disclose("refused", judged.cause, m);
109
+ settledUpTo = m.seq;
110
+ continue;
111
+ }
112
+ if (judged.verdict === "hold") {
113
+ if (!disclosedHeld.has(m.seq)) {
114
+ disclosedHeld.add(m.seq);
115
+ disclose("held", `${judged.cause}: ${judged.message}${judged.warning !== undefined ? ` (${judged.warning})` : ""}`, m);
116
+ }
117
+ break;
118
+ }
119
+ }
120
+ let fields;
121
+ try {
122
+ fields = {
123
+ from: encodePeerAddress(meta?.fromSession !== undefined ? peerSessionBoxHandle(meta.fromSession) : (m.from ?? "unknown")),
124
+ ...(meta?.fromSession !== undefined ? { fromSession: meta.fromSession } : {}),
125
+ ...(m.from !== undefined ? { fromName: m.from } : {}),
126
+ ...(meta?.fromMode !== undefined ? { fromMode: meta.fromMode } : {}),
127
+ ...(meta?.fromScope !== undefined ? { fromScope: meta.fromScope } : {}),
128
+ };
129
+ buildCrossSessionEnvelope(fields, m.content);
130
+ }
131
+ catch (e) {
132
+ disclose("refused", `record fields cannot be rendered canonically (${e instanceof CrossSessionCodecError ? e.code : e instanceof Error ? e.message : String(e)})`, m);
133
+ settledUpTo = m.seq;
134
+ continue;
135
+ }
136
+ const payload = {
137
+ task_id: boxHandle,
138
+ task_type: "background_agent",
139
+ status: "event",
140
+ summary: `cross-session message from ${m.from ?? fields.from}`,
141
+ result: m.content,
142
+ seq: m.seq,
143
+ peer: { hopChain: m.hopChain !== undefined ? [...m.hopChain] : [] },
144
+ crossSessionMessage: { ...fields, body: m.content },
145
+ _sema_provenance: { kind: "cross_session_message", from: fields.from, taskId: boxHandle, seq: m.seq, ...(meta !== undefined ? { peerMeta: { ...meta } } : {}) },
146
+ };
147
+ try {
148
+ opts.inject(payload, { priority: "next" });
149
+ }
150
+ catch (e) {
151
+ opts.onError?.(e, { phase: "degraded", sessionId, classification: "peer-box-inject" });
152
+ break;
153
+ }
154
+ settledUpTo = m.seq;
155
+ }
156
+ };
157
+ const drain = () => {
158
+ inFlight = inFlight.then(drainOnce, drainOnce);
159
+ return inFlight;
160
+ };
161
+ const finishOnce = async () => {
162
+ await ackSettled();
163
+ try {
164
+ await mailbox.releaseLease(scope, boxHandle, drainOwner);
165
+ }
166
+ catch {
167
+ }
168
+ };
169
+ const finish = () => {
170
+ inFlight = inFlight.then(finishOnce, finishOnce);
171
+ return inFlight;
172
+ };
173
+ return { drain, finish };
174
+ }
175
+ export function announcePeerLaneMount(deps, leg) {
176
+ const verdict = judgePeerLaneMount(deps, leg.sessionId);
177
+ if (verdict === undefined)
178
+ return false;
179
+ if (verdict.ok)
180
+ return true;
181
+ deliverEngineNotice(deps.onNotice, {
182
+ code: "config.peer_lane_unmounted",
183
+ message: `Cross-session lane NOT mounted for this run: ${verdict.reason}. ListAgents is not mounted and peer-session addresses refuse with that reason${verdict.code === PEER_SESSION_ID_UNGRAMMATICAL_CODE ? "." : "; wire a mailbox store that declares crossProcessSafe: true (the bundled FileMailboxStore does)."}`,
184
+ detail: { reason: verdict.reason, code: verdict.code, mailboxWired: deps.mailboxStore !== undefined, sessionId: leg.sessionId, runId: leg.runId },
185
+ });
186
+ return false;
187
+ }
188
+ export function peerLaneSendMessageSeats(args) {
189
+ return {
190
+ peerDirectory: args.peerDirectory,
191
+ ...(args.listingMounted !== undefined ? { peerListingMounted: args.listingMounted } : {}),
192
+ peerSelfSession: {
193
+ sessionId: args.sessionId,
194
+ scope: args.scope,
195
+ ...(args.name !== undefined ? { name: args.name } : {}),
196
+ modeClass: () => (args.refs.askEffective !== undefined ? foldPermissionModeClass(args.refs.askEffective) : "unknown"),
197
+ },
198
+ };
199
+ }
200
+ export function listAgentsMountable(face) {
201
+ if ((face.exclude ?? []).includes(LIST_AGENTS_TOOL_NAME))
202
+ return false;
203
+ return !face.specTools.some((t) => t.name === LIST_AGENTS_TOOL_NAME || t.name === LIST_AGENTS_TOOL_ALIAS || t.aliases?.includes(LIST_AGENTS_TOOL_NAME) || t.aliases?.includes(LIST_AGENTS_TOOL_ALIAS));
204
+ }
205
+ export function mountListAgents(args) {
206
+ if (!listAgentsMountable({ exclude: undefined, specTools: args.specTools }))
207
+ return undefined;
208
+ args.toolEffects.set(LIST_AGENTS_TOOL_NAME, "read");
209
+ args.toolEffects.set(LIST_AGENTS_TOOL_ALIAS, "read");
210
+ return createListAgentsTool({
211
+ peerDirectory: args.peerDirectory,
212
+ self: { sessionId: args.sessionId, scope: args.scope },
213
+ registry: args.registry,
214
+ access: { owner: args.hostTaskId, scope: args.scope, sessionId: args.sessionId },
215
+ ...(args.parentTaskId !== undefined ? { parentAccess: { owner: args.parentTaskId, scope: args.scope, ...(args.parentSessionId !== undefined ? { sessionId: args.parentSessionId } : {}) } } : {}),
216
+ ...(args.roster !== undefined ? { roster: args.roster } : {}),
217
+ });
218
+ }
219
+ export function bindPeerSessionDrain(harness, opts) {
220
+ const drain = createPeerSessionDrain(opts);
221
+ const round = async () => {
222
+ await drain.drain();
223
+ return undefined;
224
+ };
225
+ harness.on("before_agent_start", round);
226
+ harness.on("turn_boundary", round);
227
+ harness.subscribe((event) => (event.type === "agent_end" ? drain.finish().catch(() => undefined) : undefined));
228
+ return drain;
229
+ }
230
+ export function bindPeerLaneDrain(harness, args) {
231
+ const { deps } = args;
232
+ return bindPeerSessionDrain(harness, {
233
+ mailbox: deps.mailboxStore,
234
+ sessionId: args.sessionId,
235
+ runId: args.runId,
236
+ scope: args.scope,
237
+ inject: args.inject,
238
+ ...(deps.peerAdmission !== undefined ? { admission: deps.peerAdmission } : {}),
239
+ ownTokens: args.ownTokens,
240
+ settingLayers: () => (typeof deps.crossSessionInbound === "function" ? deps.crossSessionInbound() : deps.crossSessionInbound),
241
+ selfModeClass: () => (args.refs.askEffective !== undefined ? foldPermissionModeClass(args.refs.askEffective) : "unknown"),
242
+ ...(deps.onNotice !== undefined ? { onNotice: deps.onNotice } : {}),
243
+ ...(deps.onError !== undefined ? { onError: deps.onError } : {}),
244
+ });
245
+ }