@sema-agent/core 7.1.0 → 7.3.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/agents/cross-session-envelope.d.ts +145 -0
  3. package/dist/agents/cross-session-envelope.js +195 -0
  4. package/dist/agents/cross-session-judge.d.ts +119 -0
  5. package/dist/agents/cross-session-judge.js +184 -0
  6. package/dist/agents/cross-session-ref.d.ts +52 -0
  7. package/dist/agents/cross-session-ref.js +64 -0
  8. package/dist/agents/list-agents-tool.d.ts +55 -0
  9. package/dist/agents/list-agents-tool.js +94 -0
  10. package/dist/agents/peer-admission.d.ts +17 -1
  11. package/dist/agents/peer-admission.js +19 -2
  12. package/dist/agents/peer-directory.d.ts +208 -0
  13. package/dist/agents/peer-directory.js +272 -0
  14. package/dist/agents/peer-session-drain.d.ts +159 -0
  15. package/dist/agents/peer-session-drain.js +245 -0
  16. package/dist/agents/send-message-tool.d.ts +44 -0
  17. package/dist/agents/send-message-tool.js +181 -16
  18. package/dist/agents/subagent-steps.d.ts +11 -0
  19. package/dist/agents/subagent-steps.js +27 -4
  20. package/dist/core/auto-mode-arming.d.ts +11 -0
  21. package/dist/core/auto-mode-arming.js +7 -1
  22. package/dist/core/auto-mode-prompt.d.ts +5 -0
  23. package/dist/core/auto-mode-prompt.js +2 -1
  24. package/dist/core/auto-mode-rebuild.d.ts +2 -1
  25. package/dist/core/auto-mode-rebuild.js +2 -0
  26. package/dist/core/checkpoint-store.d.ts +203 -3
  27. package/dist/core/checkpoint-store.js +60 -19
  28. package/dist/core/governance-codes.d.ts +1 -1
  29. package/dist/core/governance-codes.js +6 -0
  30. package/dist/core/hooks.d.ts +15 -8
  31. package/dist/core/hooks.js +6 -3
  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-consent.d.ts +72 -23
  35. package/dist/core/permission-rule-consent.js +115 -26
  36. package/dist/core/permission-rule-model.d.ts +254 -51
  37. package/dist/core/permission-rule-model.js +316 -55
  38. package/dist/core/permission-rule-org.js +13 -6
  39. package/dist/core/remote-env.d.ts +8 -1
  40. package/dist/core/runner/assemble-result.js +2 -1
  41. package/dist/core/runner/prepare-task.d.ts +59 -1
  42. package/dist/core/runner/prepare-task.js +414 -149
  43. package/dist/core/runner/prepare-workspace-restore.d.ts +6 -1
  44. package/dist/core/runner/prepare-workspace-restore.js +2 -1
  45. package/dist/core/runner/runtask.js +16 -5
  46. package/dist/core/runner/tool-output-projection.js +1 -0
  47. package/dist/core/store-contracts/mailbox-store-contract.d.ts +23 -0
  48. package/dist/core/store-contracts/mailbox-store-contract.js +157 -1
  49. package/dist/core/task-notification.d.ts +93 -5
  50. package/dist/core/task-notification.js +31 -4
  51. package/dist/core/tool-policy.d.ts +11 -0
  52. package/dist/core/types.d.ts +155 -21
  53. package/dist/core/untrusted-text.js +17 -1
  54. package/dist/core/wiring-manifest.d.ts +21 -0
  55. package/dist/core/wiring-manifest.js +1 -0
  56. package/dist/index.d.ts +14 -5
  57. package/dist/index.js +13 -4
  58. package/dist/stores/cc/mailbox-store.d.ts +1 -1
  59. package/dist/stores/cc/mailbox-store.js +13 -0
  60. package/dist/stores/file/adoption/marker.d.ts +1 -1
  61. package/dist/stores/file/mailbox-store.d.ts +57 -0
  62. package/dist/stores/file/mailbox-store.js +369 -18
  63. package/package.json +1 -1
  64. package/test/export-surface.snapshot.json +233 -1
@@ -0,0 +1,184 @@
1
+ import { isPermissionModeClass } from "./cross-session-envelope.js";
2
+ export const CROSS_SESSION_INBOUND_SETTINGS = Object.freeze(["accept", "hold", "refuse"]);
3
+ export function isCrossSessionInboundSetting(value) {
4
+ return typeof value === "string" && CROSS_SESSION_INBOUND_SETTINGS.includes(value);
5
+ }
6
+ const RANK = { accept: 0, hold: 1, refuse: 2 };
7
+ function readLayer(v) {
8
+ if (v === undefined || v === null || v === "default")
9
+ return undefined;
10
+ return isCrossSessionInboundSetting(v) ? v : "invalid";
11
+ }
12
+ export function resolveCrossSessionInboundSetting(layers) {
13
+ const invalidLayers = [];
14
+ let value;
15
+ let decidedBy;
16
+ for (const [name, raw] of [
17
+ ["managed", layers.managed],
18
+ ["user", layers.user],
19
+ ]) {
20
+ const v = readLayer(raw);
21
+ if (v === "invalid") {
22
+ invalidLayers.push(name);
23
+ continue;
24
+ }
25
+ if (v !== undefined && value === undefined) {
26
+ value = v;
27
+ decidedBy = name;
28
+ }
29
+ }
30
+ for (const raw of layers.repo ?? []) {
31
+ const v = readLayer(raw);
32
+ if (v === "invalid") {
33
+ invalidLayers.push("repo");
34
+ continue;
35
+ }
36
+ if (v === undefined)
37
+ continue;
38
+ if (RANK[v] > RANK[value ?? "accept"]) {
39
+ value = v;
40
+ decidedBy = "repo";
41
+ }
42
+ else if (v !== "accept" && value !== undefined && RANK[v] === RANK[value] && decidedBy !== "managed") {
43
+ decidedBy = "repo";
44
+ }
45
+ }
46
+ if (invalidLayers.length > 0 && RANK[value ?? "accept"] < RANK.hold) {
47
+ value = "hold";
48
+ decidedBy = "invalid";
49
+ }
50
+ return { ...(value !== undefined ? { value } : {}), ...(decidedBy !== undefined ? { decidedBy } : {}), invalidLayers };
51
+ }
52
+ export const CROSS_SESSION_HOLD_CAUSES = Object.freeze([
53
+ "mode-mismatch",
54
+ "no-mode-asserted",
55
+ "explicit-setting",
56
+ "managed-setting",
57
+ "repo-setting",
58
+ "invalid-setting",
59
+ "bypass-default",
60
+ "mode-unknown",
61
+ "invalid-mode-attestation",
62
+ ]);
63
+ export function describeCrossSessionHoldCause(cause) {
64
+ switch (cause) {
65
+ case "mode-mismatch":
66
+ return "The sending session's permission mode class doesn't match this session's, so it wasn't delivered automatically.";
67
+ case "no-mode-asserted":
68
+ return "The sender did not attest its permission mode, and this session bypasses permission prompts.";
69
+ case "explicit-setting":
70
+ return 'Your "crossSessionInbound" setting is "hold".';
71
+ case "managed-setting":
72
+ return `Your organization's managed settings set "crossSessionInbound" to "hold".`;
73
+ case "repo-setting":
74
+ return `This repository's settings set "crossSessionInbound" to "hold" (your own "accept" cannot override a repo tightening).`;
75
+ case "invalid-setting":
76
+ return 'A settings file has an unrecognized "crossSessionInbound" value (see the settings warning), so messages are held while it is present.';
77
+ case "bypass-default":
78
+ return "This session is not prompting for permissions.";
79
+ case "mode-unknown":
80
+ return "This session's permission mode could not be determined.";
81
+ case "invalid-mode-attestation":
82
+ return "The sender attested an unrecognized permission mode, so the message is held for your review.";
83
+ default: {
84
+ const _exhaustive = cause;
85
+ void _exhaustive;
86
+ throw new Error(`unreachable hold cause ${String(cause)}`);
87
+ }
88
+ }
89
+ }
90
+ function describeUnknown(value) {
91
+ try {
92
+ const text = typeof value === "string" ? JSON.stringify(value) : typeof value === "bigint" ? `${value}n` : typeof value === "object" && value !== null ? JSON.stringify(value) : String(value);
93
+ return text.length > 80 ? `${text.slice(0, 80)}…` : text;
94
+ }
95
+ catch {
96
+ return `[unrenderable ${typeof value}]`;
97
+ }
98
+ }
99
+ function holdFor(cause, warning) {
100
+ return { verdict: "hold", cause, message: describeCrossSessionHoldCause(cause), ...(warning !== undefined ? { warning } : {}) };
101
+ }
102
+ function causeOf(decidedBy) {
103
+ switch (decidedBy) {
104
+ case "managed":
105
+ return "managed-setting";
106
+ case "repo":
107
+ return "repo-setting";
108
+ case "invalid":
109
+ return "invalid-setting";
110
+ case "user":
111
+ case undefined:
112
+ return "explicit-setting";
113
+ default: {
114
+ const _exhaustive = decidedBy;
115
+ void _exhaustive;
116
+ throw new Error(`unreachable setting source ${String(decidedBy)}`);
117
+ }
118
+ }
119
+ }
120
+ export function judgeCrossSessionInbound(input) {
121
+ const { setting, selfModeClass, sender } = input;
122
+ if (setting.value !== undefined) {
123
+ switch (setting.value) {
124
+ case "accept":
125
+ return { verdict: "deliver" };
126
+ case "refuse":
127
+ return { verdict: "refuse", cause: "opt-out" };
128
+ case "hold": {
129
+ const cause = causeOf(setting.decidedBy);
130
+ return holdFor(cause, cause === "invalid-setting" ? `unrecognized "crossSessionInbound" value in: ${setting.invalidLayers.join(", ")}` : undefined);
131
+ }
132
+ default: {
133
+ const _exhaustive = setting.value;
134
+ void _exhaustive;
135
+ throw new Error(`unreachable setting value ${String(setting.value)}`);
136
+ }
137
+ }
138
+ }
139
+ if (sender?.selfSent === true)
140
+ return { verdict: "deliver" };
141
+ if (selfModeClass === "unknown")
142
+ return holdFor("mode-unknown");
143
+ if (sender === undefined)
144
+ return selfModeClass === "bypass" ? holdFor("bypass-default") : { verdict: "deliver" };
145
+ if (sender.fromMode !== undefined) {
146
+ if (!isPermissionModeClass(sender.fromMode)) {
147
+ return holdFor("invalid-mode-attestation", `sender attested from-mode=${describeUnknown(sender.fromMode)} (expected bypass|prompting)`);
148
+ }
149
+ return sender.fromMode === selfModeClass ? { verdict: "deliver" } : holdFor("mode-mismatch");
150
+ }
151
+ return selfModeClass === "bypass" ? holdFor("no-mode-asserted") : { verdict: "deliver" };
152
+ }
153
+ export function foldPermissionModeClass(askEffective) {
154
+ switch (askEffective) {
155
+ case "auto_allow":
156
+ return "bypass";
157
+ case "human_reachable":
158
+ case "park_only":
159
+ case "auto_deny":
160
+ return "prompting";
161
+ case "unresolved":
162
+ return "unknown";
163
+ default: {
164
+ const _exhaustive = askEffective;
165
+ void _exhaustive;
166
+ throw new Error(`unreachable ask effective ${String(askEffective)}`);
167
+ }
168
+ }
169
+ }
170
+ export const PEER_SEND_VERDICT_CODES = Object.freeze([
171
+ "peer_send.stale_socket",
172
+ "peer_send.socket_busy",
173
+ "peer_send.too_large",
174
+ "peer_send.invalid_target",
175
+ "peer_send.timeout",
176
+ "peer_send.recipient_refuses",
177
+ "peer_send.not_reachable",
178
+ "peer_send.subscription_cap",
179
+ "peer_send.send_uncertain",
180
+ "peer_send.other",
181
+ ]);
182
+ export function peerSendVerdictSeverity(code) {
183
+ return code === "peer_send.send_uncertain" || code === "peer_send.recipient_refuses" || code === "peer_send.subscription_cap" ? "degraded" : "error";
184
+ }
@@ -0,0 +1,52 @@
1
+ /** CC `aa` / `bmn`: a ref is 6..12 hex characters — the shortest prefix of `sha256(kind:id)` that is
2
+ * unique against its sorted neighbours in the LISTING that minted it. */
3
+ export declare const PEER_REF_MIN = 6;
4
+ export declare const PEER_REF_MAX = 12;
5
+ /** CC `Mz`. */
6
+ export declare const PEER_REF_RE: RegExp;
7
+ export interface PeerRefEntry {
8
+ /** The identity kind (`session`, `agent`, …): part of the hash input so two kinds with the same id
9
+ * never share a ref. */
10
+ kind: string;
11
+ /** The durable id (a `session.<id>` box handle, an a* agent handle, …). */
12
+ id: string;
13
+ }
14
+ /**
15
+ * CC `K8` transcribed on sha256: mint the ref of EVERY entry of one listing. Refs are minted by the
16
+ * listing side and are valid only against that listing (a ref the model did not just read from a
17
+ * listing or a disambiguation error will not resolve). The optional `self` entry joins the
18
+ * disambiguation set (so no ref collides with the caller's own identity) but is not returned.
19
+ * The hash choice is local to this engine: a ref never crosses a process boundary, so the value
20
+ * domain (`[0-9a-f]{6,12}`) is what interoperates, not the digest.
21
+ */
22
+ export declare function mintPeerRef<T extends PeerRefEntry>(entries: readonly T[], self?: PeerRefEntry): Array<T & {
23
+ ref: string;
24
+ }>;
25
+ /** CC `qN` — the display token. */
26
+ export declare function formatPeerNameRef(name: string, ref: string): string;
27
+ /** CC `V8`/`DO` — split a `name [ref]` token; `undefined` when no ref suffix is present. */
28
+ export declare function parsePeerNameRef(text: string): {
29
+ name: string;
30
+ ref: string;
31
+ } | undefined;
32
+ /**
33
+ * CC `dr` — the MATCHING key of a peer/session name: NFKC → strip control/format code points → trim →
34
+ * lower-case → collapse whitespace runs to `-`. Every terminal resolves and compares names on this
35
+ * key (a display name is never the key).
36
+ */
37
+ export declare function normalizePeerName(name: string): string;
38
+ /** The box-address prefixes of design/385 (`session.<id>` per-session box, `principal.<…>` the
39
+ * cross-principal inbox family). Reserved: a NAME may never spell an address. */
40
+ export declare const PEER_ADDRESS_PREFIXES: readonly string[];
41
+ /**
42
+ * design/385 §2.2 namespace protection (CC `qd` in spirit): a candidate spawn/session name is refused
43
+ * when its normalized key would shadow an explicit address — a reserved box prefix, a transport
44
+ * scheme (`uds:` / `bridge:` / `did:`), a socket path, an `@`-bearing team form, or the `*`
45
+ * broadcast token. Returns the reason, or `undefined` when the name is admissible. Registration-side
46
+ * check (loud refusal at write time); the resolution ladder ranks explicit addresses first regardless.
47
+ *
48
+ * The socket-path arm is judged on the RAW name AND on its normalized key (CC `QHe` runs its
49
+ * classifier over both): a name that is a socket path in either spelling is refused, since the
50
+ * resolution ladder would read the raw spelling as an address.
51
+ */
52
+ export declare function reservedPeerNameReason(name: string): "empty" | "address_prefix" | "transport_scheme" | "socket_path" | "at_sign" | "wildcard" | undefined;
@@ -0,0 +1,64 @@
1
+ import { createHash } from "node:crypto";
2
+ export const PEER_REF_MIN = 6;
3
+ export const PEER_REF_MAX = 12;
4
+ export const PEER_REF_RE = /^[0-9a-f]{6,12}$/;
5
+ function refHash(e) {
6
+ return createHash("sha256").update(`${e.kind}:${e.id}`).digest("hex");
7
+ }
8
+ function commonPrefixLen(a, b) {
9
+ let n = 0;
10
+ while (n < a.length && n < b.length && a[n] === b[n])
11
+ n++;
12
+ return n;
13
+ }
14
+ export function mintPeerRef(entries, self) {
15
+ const hashes = entries.map(refHash);
16
+ const universe = [...new Set([...hashes, ...(self !== undefined ? [refHash(self)] : [])])].sort();
17
+ const lengths = new Map();
18
+ for (let i = 0; i < universe.length; i++) {
19
+ const h = universe[i];
20
+ const shared = Math.max(i > 0 ? commonPrefixLen(h, universe[i - 1]) : 0, i + 1 < universe.length ? commonPrefixLen(h, universe[i + 1]) : 0);
21
+ lengths.set(h, Math.min(h.length, Math.max(PEER_REF_MIN, shared + 1)));
22
+ }
23
+ return entries.map((e, i) => {
24
+ const h = hashes[i];
25
+ return { ...e, ref: h.slice(0, Math.min(lengths.get(h), PEER_REF_MAX)) };
26
+ });
27
+ }
28
+ export function formatPeerNameRef(name, ref) {
29
+ return `${name} [${ref}]`;
30
+ }
31
+ export function parsePeerNameRef(text) {
32
+ const m = /^(.*\S)\s*\[([0-9a-f]{6,12})\]$/.exec(text.trim());
33
+ return m === null ? undefined : { name: m[1], ref: m[2] };
34
+ }
35
+ export function normalizePeerName(name) {
36
+ return name
37
+ .normalize("NFKC")
38
+ .replace(/[\p{Cc}\p{Cf}]/gu, "")
39
+ .trim()
40
+ .toLowerCase()
41
+ .replace(/\s+/g, "-");
42
+ }
43
+ export const PEER_ADDRESS_PREFIXES = Object.freeze(["session.", "principal."]);
44
+ const SOCKET_PATH_RE = /^\/\S*\.sock/;
45
+ const NAMED_PIPE_RE = /^[\\/]{2}[.?][\\/]pipe[\\/]/i;
46
+ function isSocketPathSpelling(text) {
47
+ return SOCKET_PATH_RE.test(text) || NAMED_PIPE_RE.test(text);
48
+ }
49
+ export function reservedPeerNameReason(name) {
50
+ const key = normalizePeerName(name);
51
+ if (key === "")
52
+ return "empty";
53
+ if (key === "*")
54
+ return "wildcard";
55
+ if (key.includes("@"))
56
+ return "at_sign";
57
+ if (PEER_ADDRESS_PREFIXES.some((p) => key.startsWith(p)))
58
+ return "address_prefix";
59
+ if (/^(?:uds|bridge|did):/.test(key))
60
+ return "transport_scheme";
61
+ if (isSocketPathSpelling(name) || isSocketPathSpelling(key))
62
+ return "socket_path";
63
+ return undefined;
64
+ }
@@ -0,0 +1,55 @@
1
+ import { Type } from "typebox";
2
+ import type { TaskAccess } from "../core/task-registry.js";
3
+ import { type RosterEntry } from "./roster-store.js";
4
+ import { type PeerDirectory } from "./peer-directory.js";
5
+ export declare const LIST_AGENTS_TOOL_NAME = "ListAgents";
6
+ export declare const LIST_AGENTS_TOOL_ALIAS = "ListPeers";
7
+ /** CC `maxResultSizeChars` for this tool. */
8
+ export declare const LIST_AGENTS_MAX_RESULT_CHARS = 10000;
9
+ export interface ListAgentsToolOptions {
10
+ /** The peer-session directory (the mount reason; without it the tool is not mounted). */
11
+ peerDirectory: PeerDirectory;
12
+ /** This session's own identity — excluded from the peer listing, included in ref disambiguation. */
13
+ self: {
14
+ sessionId: string;
15
+ scope: string;
16
+ };
17
+ /** The live registry + this caller's access axes (same axes the SendMessage mount reads). */
18
+ registry?: {
19
+ list(access: TaskAccess, opts?: {
20
+ limit?: number;
21
+ }): Array<{
22
+ task_id: string;
23
+ type: string;
24
+ status: string;
25
+ description?: string;
26
+ }>;
27
+ getAccessibleTask(id: string, access: TaskAccess): {
28
+ name?: string;
29
+ status: string;
30
+ } | undefined;
31
+ };
32
+ access?: TaskAccess;
33
+ /** design/147 S3a parity — a delegated child's TRUSTED parent view (owner = the parent's task id,
34
+ * sessionId = the parent's session), so the siblings SendMessage can reach by name are listed too.
35
+ * Unioned with {@link access}, de-duplicated by id; the scope axis is never widened. */
36
+ parentAccess?: TaskAccess;
37
+ /** The durable roster (names recorded by earlier processes). Rows are filtered by the caller's
38
+ * {@link access} with the store's own predicate (`entryAccessible`) BEFORE anything is listed — a
39
+ * shared roster holds other owners'/scopes' names, and a listing is a disclosure face. */
40
+ roster?: {
41
+ list(): RosterEntry[] | Promise<RosterEntry[]>;
42
+ };
43
+ /** Include dead (offline) peer sessions in the listing. Default false (CC observable form). */
44
+ includeOffline?: boolean;
45
+ }
46
+ /** One rendered row. `kind` names the lane; `address` is what SendMessage accepts. */
47
+ export interface ListAgentsRow {
48
+ kind: "peer_session" | "background_agent" | "roster";
49
+ name: string;
50
+ /** Peer rows only: the listing-minted ref. */
51
+ ref?: string;
52
+ address: string;
53
+ state: "busy" | "idle" | "offline" | "running" | "finished" | "unknown";
54
+ }
55
+ export declare function createListAgentsTool(opts: ListAgentsToolOptions): import("../internal/harness-types.js").AgentTool<Type.TObject<{}>, unknown>;
@@ -0,0 +1,94 @@
1
+ import { Type } from "typebox";
2
+ import { defineTool } from "../core/tools.js";
3
+ import { inlineUntrusted } from "../core/untrusted-text.js";
4
+ import { formatPeerNameRef } from "./cross-session-ref.js";
5
+ import { entryAccessible } from "./roster-store.js";
6
+ import { mintPeerSessionCandidates, peerSessionBoxHandle } from "./peer-directory.js";
7
+ export const LIST_AGENTS_TOOL_NAME = "ListAgents";
8
+ export const LIST_AGENTS_TOOL_ALIAS = "ListPeers";
9
+ export const LIST_AGENTS_MAX_RESULT_CHARS = 10_000;
10
+ export function createListAgentsTool(opts) {
11
+ return defineTool({
12
+ name: LIST_AGENTS_TOOL_NAME,
13
+ aliases: [LIST_AGENTS_TOOL_ALIAS],
14
+ contract: { contractId: "core.list_agents@1", implementationRevision: "1" },
15
+ effect: "read",
16
+ isConcurrencySafe: () => true,
17
+ description: `List the agents and peer sessions you can message with SendMessage. Peer sessions are other sessions of this ` +
18
+ `engine running for the same user on this machine, shown as \`name [ref]\`; send to one with its name (or \`name [ref]\` ` +
19
+ `when a name is ambiguous, or its \`session.<id>\` address). A peer that is offline still accepts messages — they are parked ` +
20
+ `durably in its session box and delivered when it next runs. Background agents you spawned are listed with their task_id. ` +
21
+ `A ref is only valid against the listing (or disambiguation error) you read it from — do not invent one.`,
22
+ parameters: Type.Object({}),
23
+ execute: async () => {
24
+ const rows = [];
25
+ let peerRows = [];
26
+ let directoryFault;
27
+ try {
28
+ peerRows = await opts.peerDirectory.listPeerSessions({ scope: opts.self.scope });
29
+ }
30
+ catch (e) {
31
+ directoryFault = e instanceof Error ? e.message : String(e);
32
+ }
33
+ for (const c of mintPeerSessionCandidates(peerRows, { sessionId: opts.self.sessionId })) {
34
+ const r = c.record;
35
+ if (r.sessionId.toLowerCase() === opts.self.sessionId.toLowerCase())
36
+ continue;
37
+ if (r.liveness === "deleted")
38
+ continue;
39
+ if (r.liveness === "dead" && opts.includeOffline !== true)
40
+ continue;
41
+ rows.push({
42
+ kind: "peer_session",
43
+ name: r.name,
44
+ ref: c.ref,
45
+ address: peerSessionBoxHandle(r.sessionId),
46
+ state: r.liveness === "dead" ? "offline" : r.tempo === "active" || r.tempo === "blocked" ? "busy" : "idle",
47
+ });
48
+ }
49
+ let registryCapped = false;
50
+ const views = [opts.access, opts.parentAccess].filter((a) => a !== undefined);
51
+ const seenIds = new Set();
52
+ if (opts.registry !== undefined) {
53
+ for (const view of views) {
54
+ const listed = opts.registry.list(view, { limit: 500 });
55
+ registryCapped ||= listed.length >= 500;
56
+ for (const t of listed) {
57
+ if (t.type !== "background_agent" || seenIds.has(t.task_id))
58
+ continue;
59
+ seenIds.add(t.task_id);
60
+ const full = opts.registry.getAccessibleTask(t.task_id, view);
61
+ rows.push({ kind: "background_agent", name: full?.name ?? t.description ?? t.task_id, address: t.task_id, state: t.status === "running" || t.status === "pending" ? "running" : "finished" });
62
+ }
63
+ }
64
+ }
65
+ if (opts.roster !== undefined && views.length > 0) {
66
+ try {
67
+ for (const e of await opts.roster.list()) {
68
+ const view = views.find((v) => entryAccessible(e, v));
69
+ if (view === undefined || seenIds.has(e.agentId))
70
+ continue;
71
+ seenIds.add(e.agentId);
72
+ const live = opts.registry?.getAccessibleTask(e.agentId, view);
73
+ rows.push({ kind: "roster", name: e.name, address: e.agentId, state: live === undefined ? "unknown" : live.status === "running" || live.status === "pending" ? "running" : "finished" });
74
+ }
75
+ }
76
+ catch {
77
+ }
78
+ }
79
+ const lines = rows.map((r) => {
80
+ const label = r.kind === "peer_session" ? formatPeerNameRef(inlineUntrusted(r.name, 64), r.ref) : inlineUntrusted(r.name, 64);
81
+ return `${label} · ${r.state} · ${r.kind === "peer_session" ? "peer session" : r.kind === "roster" ? "agent (durable record)" : "agent"} · ${r.address}`;
82
+ });
83
+ const finishedCount = rows.filter((r) => r.state === "finished").length;
84
+ const header = rows.length === 0 ? "No agents or peer sessions are listed right now." : `${rows.length} listed:`;
85
+ const finishedNote = finishedCount > 0 ? `\n(${finishedCount} finished agent(s): continuable by SendMessage only while its session is retained or a durable record exists — otherwise SendMessage answers with an honest refusal)` : "";
86
+ const fault = (directoryFault !== undefined ? `\n(peer-session directory unreadable: ${inlineUntrusted(directoryFault, 200)} — peer rows may be missing)` : "") +
87
+ (registryCapped ? "\n(the live task list is at its 500-row window: older background agents may be missing from this listing — address them by task_id)" : "");
88
+ let content = `${header}${lines.length > 0 ? `\n${lines.join("\n")}` : ""}${finishedNote}${fault}`;
89
+ if (content.length > LIST_AGENTS_MAX_RESULT_CHARS)
90
+ content = `${content.slice(0, LIST_AGENTS_MAX_RESULT_CHARS)}\n[listing truncated: ${content.length} chars total]`;
91
+ return { content, details: { type: "list-agents", rows, ...(directoryFault !== undefined ? { directoryFault: true } : {}) } };
92
+ },
93
+ });
94
+ }
@@ -86,6 +86,10 @@ export interface PeerInboundChainRef {
86
86
  current: string[];
87
87
  }
88
88
  export declare function createPeerInboundChainRef(seed?: readonly string[]): PeerInboundChainRef;
89
+ /** design/385 §4.6 — how many admitted (seq → body) pairs a sender's replay ledger keeps: more than
90
+ * any one leased batch a drain injects between two acks (a batch is at most the queued-message bound,
91
+ * 50 by default), so an ack fault re-presents every message of the batch as a re-presentation. */
92
+ export declare const REPLAY_LEDGER_MAX = 128;
89
93
  export interface PeerAdmissionRequest {
90
94
  /** The sender's canonical identity key ({@link PeerIdentity.key}), or undefined for an
91
95
  * identity-less direct mount — rate/dedup are then SKIPPED (there is no honest bucket key),
@@ -99,6 +103,11 @@ export interface PeerAdmissionRequest {
99
103
  prospectiveChain: readonly string[];
100
104
  /** The RECIPIENT's self-token set, as knowable at this entry point. */
101
105
  ownTokens: readonly string[];
106
+ /** design/385 §4.6 — the mailbox seq of a DRAIN-stage re-check (absent at the send point). Dedup
107
+ * judges body+seq: same body, same seq ⇒ a re-presentation of an already-admitted message (pass);
108
+ * same body, other seq inside the window ⇒ duplicate. No shared state across consumers is needed:
109
+ * a different consumer process holds a fresh gate and admits the redelivery by construction. */
110
+ seq?: number;
102
111
  }
103
112
  export type PeerAdmissionVerdict = {
104
113
  ok: true;
@@ -135,7 +144,14 @@ export declare function peerAdmissionFor(scope: string | undefined, recipientKey
135
144
  * always commits a seat, which is right for a host asking for the instance itself, and wrong at a
136
145
  * gate that has not admitted anything yet).
137
146
  */
138
- export declare function judgePeerAdmission(scope: string | undefined, recipientKey: string, req: PeerAdmissionRequest, config: PeerAdmissionConfig, options?: PeerAdmissionOptions): PeerAdmissionVerdict;
147
+ /** design/385 §4.6 the two-point gate's STAGE discriminator. The cross-session lane judges a message
148
+ * twice, in two processes or (single-process hosts) in one: at the SEND point before the append, and at
149
+ * the recipient's DRAIN point before injection. The registry is process-global and keyed by
150
+ * (scope, recipient), so without a stage in the key the drain re-check of a message this same process
151
+ * sent would read the send-point body record and refuse its own message as `duplicate`. Two gate
152
+ * instances, one per stage; the stage-less key is the pre-385 spelling and stays byte-identical. */
153
+ export type PeerAdmissionStage = "send" | "drain";
154
+ export declare function judgePeerAdmission(scope: string | undefined, recipientKey: string, req: PeerAdmissionRequest, config: PeerAdmissionConfig, options?: PeerAdmissionOptions, stage?: PeerAdmissionStage): PeerAdmissionVerdict;
139
155
  /** Test seam ONLY (module-level registry hygiene between test files — same posture as the lane map,
140
156
  * which drains itself; this one is long-lived by design so tests reset it explicitly). */
141
157
  export declare function resetPeerAdmissionRegistryForTests(): void;
@@ -60,6 +60,7 @@ export function createPeerSelfRef(scope) {
60
60
  export function createPeerInboundChainRef(seed) {
61
61
  return { current: seed !== undefined ? [...seed] : [] };
62
62
  }
63
+ export const REPLAY_LEDGER_MAX = 128;
63
64
  function bodyFingerprint(body) {
64
65
  return createHash("sha256").update(body).digest("hex");
65
66
  }
@@ -95,6 +96,8 @@ export function createPeerAdmission(options) {
95
96
  const existing = senders.get(req.senderKey);
96
97
  const s = existing ?? { tokens: config.bucketCapacity, lastRefillAt: t };
97
98
  const fingerprint = bodyFingerprint(req.body);
99
+ if (req.seq !== undefined && s.replay?.get(req.seq) === fingerprint)
100
+ return { ok: true };
98
101
  if (config.dedupWindowMs > 0 && s.lastBodyHash === fingerprint && s.lastBodyAt !== undefined && t - s.lastBodyAt < config.dedupWindowMs) {
99
102
  refusals.duplicate++;
100
103
  return { ok: false, reason: "duplicate" };
@@ -109,6 +112,17 @@ export function createPeerAdmission(options) {
109
112
  s.lastRefillAt = t;
110
113
  s.lastBodyHash = fingerprint;
111
114
  s.lastBodyAt = t;
115
+ if (req.seq !== undefined) {
116
+ s.replay ??= new Map();
117
+ s.replay.delete(req.seq);
118
+ s.replay.set(req.seq, fingerprint);
119
+ while (s.replay.size > Math.max(REPLAY_LEDGER_MAX, config.maxQueuedPeerMessages, config.bucketCapacity)) {
120
+ const oldest = s.replay.keys().next().value;
121
+ if (oldest === undefined)
122
+ break;
123
+ s.replay.delete(oldest);
124
+ }
125
+ }
112
126
  senders.delete(req.senderKey);
113
127
  senders.set(req.senderKey, s);
114
128
  while (senders.size > config.maxTrackedSenders) {
@@ -145,8 +159,11 @@ export function peerAdmissionFor(scope, recipientKey, config, options) {
145
159
  }
146
160
  return inst;
147
161
  }
148
- export function judgePeerAdmission(scope, recipientKey, req, config, options) {
149
- const key = JSON.stringify([scope ?? "", recipientKey]);
162
+ function peerAdmissionRegistryKey(scope, recipientKey, stage) {
163
+ return stage === undefined ? JSON.stringify([scope ?? "", recipientKey]) : JSON.stringify([scope ?? "", recipientKey, stage]);
164
+ }
165
+ export function judgePeerAdmission(scope, recipientKey, req, config, options, stage) {
166
+ const key = peerAdmissionRegistryKey(scope, recipientKey, stage);
150
167
  const resident = peerAdmissionRegistry.get(key);
151
168
  const inst = resident ?? createPeerAdmission(options);
152
169
  const verdict = inst.admit(req, config);