@sema-agent/core 7.24.4 → 7.25.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 (107) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/agents/peer-session-drain.d.ts +16 -1
  3. package/dist/agents/peer-session-drain.js +16 -1
  4. package/dist/agents/send-message-tool.d.ts +10 -2
  5. package/dist/agents/send-message-tool.js +18 -7
  6. package/dist/agents/subagent.js +13 -10
  7. package/dist/core/arg-summary.d.ts +0 -7
  8. package/dist/core/arg-summary.js +2 -1
  9. package/dist/core/background-agent-store.d.ts +3 -3
  10. package/dist/core/checkpoint-store.d.ts +1 -0
  11. package/dist/core/delegation-frames.d.ts +5 -0
  12. package/dist/core/gate-fold.js +85 -0
  13. package/dist/core/gate-lanes.js +23 -1
  14. package/dist/core/gate-outcome.d.ts +8 -2
  15. package/dist/core/gate-outcome.js +3 -1
  16. package/dist/core/hooks.d.ts +29 -0
  17. package/dist/core/hooks.js +2 -0
  18. package/dist/core/mailbox-store.d.ts +5 -0
  19. package/dist/core/mailbox-store.js +1 -1
  20. package/dist/core/parent-spec-seat.d.ts +7 -1
  21. package/dist/core/parent-spec-seat.js +2 -0
  22. package/dist/core/permission-rule-consent.js +29 -47
  23. package/dist/core/permission-rule-provider.js +4 -3
  24. package/dist/core/permission-rule-store.d.ts +143 -7
  25. package/dist/core/permission-rule-store.js +165 -15
  26. package/dist/core/runner/contracts.d.ts +36 -2
  27. package/dist/core/runner/gate-exit.d.ts +14 -0
  28. package/dist/core/runner/gate-exit.js +4 -0
  29. package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
  30. package/dist/core/runner/prepare-delegation-surface.js +2 -1
  31. package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
  32. package/dist/core/runner/prepare-gate-stations.js +7 -4
  33. package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
  34. package/dist/core/runner/prepare-hands-readface.js +25 -10
  35. package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
  36. package/dist/core/runner/prepare-inherited-gate.js +11 -6
  37. package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
  38. package/dist/core/runner/prepare-policy-chain.js +14 -4
  39. package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
  40. package/dist/core/runner/prepare-suspend-saga.js +4 -3
  41. package/dist/core/runner/prepare-task.js +10 -9
  42. package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
  43. package/dist/core/runner/prepare-wiring-manifest.js +8 -4
  44. package/dist/core/runner/resume-apply.js +2 -0
  45. package/dist/core/runner/run-harness-handlers.js +1 -0
  46. package/dist/core/runner/run-identity-wiring.d.ts +2 -2
  47. package/dist/core/runner/run-leg.js +26 -0
  48. package/dist/core/runner/run-notification-lane.d.ts +2 -2
  49. package/dist/core/runner/run-notification-lane.js +2 -1
  50. package/dist/core/runner/run-settle-and-teardown.js +1 -0
  51. package/dist/core/runner/session-rule-policy.d.ts +3 -4
  52. package/dist/core/runner/session-rule-policy.js +11 -3
  53. package/dist/core/session-policy-store.d.ts +27 -3
  54. package/dist/core/session-policy-store.js +12 -4
  55. package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
  56. package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
  57. package/dist/core/store-contracts/peer-directory-contract.js +82 -0
  58. package/dist/core/task-event.d.ts +6 -0
  59. package/dist/core/task-notification.d.ts +79 -4
  60. package/dist/core/task-notification.js +90 -1
  61. package/dist/core/task-registry-agent.js +1 -2
  62. package/dist/core/task-registry-monitor.js +2 -1
  63. package/dist/core/task-registry-shared.d.ts +53 -5
  64. package/dist/core/task-registry-shared.js +13 -2
  65. package/dist/core/task-registry.js +8 -5
  66. package/dist/core/task-spec.d.ts +12 -5
  67. package/dist/core/tool-catalog-entries.js +1 -0
  68. package/dist/core/tool-policy.js +30 -23
  69. package/dist/core/tool-spec.d.ts +1 -1
  70. package/dist/core/wiring-manifest.d.ts +2 -2
  71. package/dist/engine/loop/agent-loop.js +1 -1
  72. package/dist/engine/loop/types.d.ts +4 -0
  73. package/dist/index.d.ts +6 -3
  74. package/dist/index.js +4 -3
  75. package/dist/prompts/coordinator.d.ts +2 -1
  76. package/dist/stores/file/adoption/adopt.js +23 -1
  77. package/dist/stores/file/permission-rule-store.js +3 -1
  78. package/dist/stores/file/session-policy-store.d.ts +31 -0
  79. package/dist/stores/file/session-policy-store.js +87 -7
  80. package/dist/tools/fs/bash-lexer.d.ts +13 -0
  81. package/dist/tools/fs/bash-lexer.js +61 -0
  82. package/dist/tools/fs/bash-program-position.d.ts +36 -29
  83. package/dist/tools/fs/bash-program-position.js +33 -13
  84. package/dist/tools/fs/bash-readonly-classifier.d.ts +26 -313
  85. package/dist/tools/fs/bash-readonly-classifier.js +81 -782
  86. package/dist/tools/fs/fs-bash.d.ts +43 -207
  87. package/dist/tools/fs/fs-bash.js +20 -341
  88. package/dist/tools/fs/index.d.ts +12 -13
  89. package/dist/tools/fs/index.js +3 -4
  90. package/dist/tools/fs/read-deny.d.ts +25 -0
  91. package/dist/tools/fs/read-deny.js +19 -0
  92. package/dist/tools/fs/safety.js +2 -6
  93. package/dist/tools/fs/shell-read-check.d.ts +99 -0
  94. package/dist/tools/fs/shell-read-check.js +229 -0
  95. package/dist/tools/fs/shell-read-establish.d.ts +70 -115
  96. package/dist/tools/fs/shell-read-establish.js +216 -102
  97. package/dist/tools/fs/shell-read-judge.d.ts +231 -0
  98. package/dist/tools/fs/shell-read-judge.js +170 -0
  99. package/dist/tools/fs/shell-read-plan.d.ts +3 -0
  100. package/dist/tools/fs/shell-read-plan.js +88 -27
  101. package/dist/tools/monitor.d.ts +5 -5
  102. package/dist/tools/monitor.js +3 -5
  103. package/dist/tools/task-list.d.ts +4 -0
  104. package/package.json +1 -1
  105. package/test/export-surface.snapshot.json +19 -3
  106. package/wire-changes/core@7.25.0.json +671 -0
  107. package/wire-changes/core@7.25.1.json +64 -0
@@ -1,5 +1,5 @@
1
1
  import type { PeerInboundChainRef } from "../../agents/peer-admission.js";
2
- import { type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
2
+ import { type NotificationInjectionOutcome, type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
3
3
  import type { PushQueue } from "../push-queue.js";
4
4
  import type { TaskEvent, TaskSpec } from "../types.js";
5
5
  import type { Stats } from "./assemble-result.js";
@@ -32,7 +32,7 @@ export interface RunIdentityWiringInput {
32
32
  /** borrowed-readonly — the notification lane's ONE injection entry (the R0 lane's). */
33
33
  injectTaskNotification: (notification: TaskNotificationPayload, opts?: {
34
34
  priority?: SystemInjectionPriority;
35
- }) => Promise<"queued" | "parked" | "dropped_duplicate">;
35
+ }) => Promise<NotificationInjectionOutcome>;
36
36
  /** borrowed-mutable — the turn-open dedup set (the cross-layer double-park dedup): a redelivered frame's key is registered here. */
37
37
  deliveredAtTurnOpen: Set<string>;
38
38
  /** borrowed-mutable — the peer inbound chain, overwritten by each consumed peer-class payload. */
@@ -19,6 +19,7 @@ import { DEFAULT_PRECALL_OUTPUT_TOKENS, platformLimitTerminal } from "./clock-an
19
19
  import { deepJsonEqual, resumeContinuation } from "./decide-continuation.js";
20
20
  import { settleExecutionRecord } from "./execution-record.js";
21
21
  import { mintGateOutcome } from "./gate-exit.js";
22
+ import { withGateTimeout } from "../gate-bounded-await.js";
22
23
  import { resolveGitLegDelivery, wrapGitFrame } from "./git-leg-delivery.js";
23
24
  import { GIT_STATUS_ECHO_PREVIEW } from "./git-status-frame.js";
24
25
  import { toImageContent } from "./image.js";
@@ -678,6 +679,31 @@ async function resolvePendingCall(runner, prepared, resume, emit, emitCommitted,
678
679
  return;
679
680
  }
680
681
  }
682
+ if (prepared.shellReadCheck !== undefined) {
683
+ const edited = outcome.updatedInput !== undefined && !deepJsonEqual(outcome.updatedInput, pendingAction.args);
684
+ const judged = prepared.shellReadCheck.judge(pendingAction.toolName, resolvedArgs, prepared.cwdRef?.current, prepared.abortController.signal, {
685
+ toolCallId: pendingAction.toolCallId,
686
+ ...(edited ? { seat: "rejudge" } : {}),
687
+ });
688
+ if (judged !== null) {
689
+ let verdict;
690
+ let answered = true;
691
+ try {
692
+ verdict = await withGateTimeout(judged, ORG_ADJUDICATION_TIMEOUT_MS, prepared.abortController.signal, "shellReadStation");
693
+ }
694
+ catch {
695
+ answered = false;
696
+ verdict = prepared.shellReadCheck.unanswered(pendingAction.toolName, "the read station could not answer on the resumed leg");
697
+ }
698
+ if (!answered || verdict.disposition === "refuse" || (edited && verdict.disposition !== "vouch")) {
699
+ const readDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" was not executed: ${verdict.sentence ?? "the read boundary does not vouch for what it reads on this leg"} This approval is spent — the call has to be re-issued and approved again.`, prepared.reminderMark);
700
+ await settleEnd(true, { content: readDenial, ...(verdict.details !== undefined ? { details: verdict.details } : {}) }, vetoed("read_boundary"));
701
+ const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, readDenial, true));
702
+ emitCommitted(eid, "toolResult", pendingAction.toolCallId);
703
+ return;
704
+ }
705
+ }
706
+ }
681
707
  const tool = prepared.tools.find((t) => t.name === pendingAction.toolName);
682
708
  if (!tool) {
683
709
  const e = new Error(`the approved tool "${pendingAction.toolName}" is no longer available on resume`);
@@ -1,7 +1,7 @@
1
1
  import { type PeerInboundChainRef } from "../../agents/peer-admission.js";
2
2
  import type { PushQueue } from "../push-queue.js";
3
3
  import type { SessionStore } from "../session.js";
4
- import { type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
4
+ import { type NotificationInjectionOutcome, type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
5
5
  import type { RunnerDeps, TaskEvent, TaskSpec } from "../types.js";
6
6
  import type { SessionReadFileStates } from "./prepare-hands-readface.js";
7
7
  import type { NotificationLaneBindings, Prepared, PrepareTaskFn, ResumeRun, RunInternals, RunnerDepsSeat, RunnerSelfSeat, TaskIdRef } from "./contracts.js";
@@ -41,7 +41,7 @@ export interface RunNotificationLaneResult {
41
41
  /** The ONE injection entry every notification producer rides (the identity lane binds the notify() bridge to it). */
42
42
  injectTaskNotification: (notification: TaskNotificationPayload, opts?: {
43
43
  priority?: SystemInjectionPriority;
44
- }) => Promise<"queued" | "parked" | "dropped_duplicate">;
44
+ }) => Promise<NotificationInjectionOutcome>;
45
45
  /** The turn-open dedup set (the cross-layer double-park dedup): the identity lane registers a redelivered frame's key here. */
46
46
  deliveredAtTurnOpen: Set<string>;
47
47
  /** The peer inbound chain ref this run carries (the spawn chain's, or a fresh one). */
@@ -118,9 +118,10 @@ export async function runNotificationLane(input) {
118
118
  }
119
119
  catch {
120
120
  }
121
- injectTaskNotification(notification, opts);
121
+ return injectTaskNotification(notification, opts);
122
122
  },
123
123
  }, self, taskIdRef);
124
+ pendingSessionNotifications.bindSessionBox(prepared.sessionId, prepared.sessionBox);
124
125
  const notificationLane = {
125
126
  get harness() { return notificationHarness; }, set harness(h) { notificationHarness = h; },
126
127
  get sessionId() { return notificationSessionId; },
@@ -266,6 +266,7 @@ export async function runSettleAndTeardown(input) {
266
266
  ...(internals?.cycleSeq !== undefined ? { seq: internals.cycleSeq } : {}),
267
267
  ...(internals?.parentTaskId !== undefined ? { parentTaskId: internals.parentTaskId } : {}),
268
268
  ...(subagentName ? { name: subagentName } : {}),
269
+ ...(internals?.explicitAgentName !== undefined ? { spawnName: internals.explicitAgentName } : {}),
269
270
  model: prepared.model.id,
270
271
  usage: { totalTokens: stats.tokens, toolUses: stats.toolCalls, durationMs: Date.now() - rs.telemetry.taskStart },
271
272
  status: result.terminal.kind === "completed" ? "completed" : "failed",
@@ -87,10 +87,9 @@ export interface SessionRuleSource {
87
87
  * DELETED and written again starts over at 1. At `>=`, a recreated row whose rev merely TIES the
88
88
  * enforced one would replace it — an unauthorized loosening dressed as an update. At `>`, a tie enforces
89
89
  * the floor, which is a no-op for the honest case (equal rev is the same row) and fail-closed for the
90
- * recycled one. It raises the cost of the recycle rather than closing it: the rev can still be climbed
91
- * past the floor by repeated writes, because the tighten gate has no prior row to compare against after
92
- * a delete. Closing it belongs to the STORE's rev lifecycle (a counter that survives deletion, or a
93
- * generation identity), which is a cross-backend contract change, not a reader-side one.
90
+ * recycled one. Closed since 7.25.0 by the STORE's lineage identity (`StoredSessionRules.gen`): a
91
+ * recreated row is another generation and is never compared to the enforced one, whatever its rev, and
92
+ * a backend without lineages (gen 0 throughout) keeps the `>` protection above.
94
93
  *
95
94
  * The per-call handoff mirrors the permission-rule lanes': the first reader of a `toolCallId` takes the read
96
95
  * and hands it on, a second reader of the SAME call claims that one snapshot, and an unclaimed entry is
@@ -60,9 +60,17 @@ export function createLiveSessionRules(store, floor, hooks) {
60
60
  }
61
61
  return enforced;
62
62
  }
63
- if (vanished) {
64
- announce(`session ${JSON.stringify(floor.sessionId)} has a new rules row at rev ${live.rev}, which does not continue the deleted row this leg enforces`);
65
- return enforced;
63
+ if (enforced.rev > 0) {
64
+ if (live.gen !== undefined && enforced.gen !== undefined) {
65
+ if (live.gen !== enforced.gen) {
66
+ announce(`session ${JSON.stringify(floor.sessionId)} has a rules row of a new lineage (generation ${live.gen}, rev ${live.rev}), which does not continue the row this leg enforces (generation ${enforced.gen})`);
67
+ return enforced;
68
+ }
69
+ }
70
+ else if (vanished) {
71
+ announce(`session ${JSON.stringify(floor.sessionId)} has a new rules row at rev ${live.rev} after the row this leg enforced was deleted, and its backend carries no lineage to continue it by`);
72
+ return enforced;
73
+ }
66
74
  }
67
75
  if (live.rev <= enforced.rev) {
68
76
  if (live.rev < enforced.rev) {
@@ -36,6 +36,11 @@ export interface SessionPermissionRules {
36
36
  /** {@link SessionPermissionRules} plus the monotonic `rev` the store stamps (the OCC key). */
37
37
  export interface StoredSessionRules extends SessionPermissionRules {
38
38
  rev: number;
39
+ /** The row's LINEAGE: which generation of this key the row belongs to. `rev` counts within one lineage; an
40
+ * erase (`deleteBySession`) ends it, and the next write on the key starts the next generation, so a reader
41
+ * can tell a recreated row from a continuation of the one it enforces whatever `rev` the new row wears.
42
+ * Absent = generation 0 (a backend that predates the mark, or a row written before it). */
43
+ gen?: number;
39
44
  }
40
45
  /** Options for {@link SessionPolicyStore.putRules}. */
41
46
  export interface PutRulesOptions {
@@ -47,10 +52,11 @@ export interface PutRulesOptions {
47
52
  operator?: boolean;
48
53
  }
49
54
  /** Typed error from {@link SessionPolicyStore.putRules}. `conflict` = stale `expectedRev`; `loosen_forbidden`
50
- * = a non-operator write tried to relax the rules. */
55
+ * = a non-operator write tried to relax the rules; `corrupt` = a backend could not read the erase-surviving
56
+ * rev mark ({@link SessionPolicyStore.deleteBySession}) and refuses rather than stamp an unproven rev. */
51
57
  export declare class SessionPolicyError extends Error {
52
- readonly code: "conflict" | "loosen_forbidden";
53
- constructor(code: "conflict" | "loosen_forbidden", message: string);
58
+ readonly code: "conflict" | "loosen_forbidden" | "corrupt";
59
+ constructor(code: "conflict" | "loosen_forbidden" | "corrupt", message: string);
54
60
  }
55
61
  /**
56
62
  * The seam a deployment implements for DURABLE, cross-replica per-session rules. Unset on `RunnerDeps` ⇒ the
@@ -80,6 +86,17 @@ export interface SessionPolicyStore {
80
86
  * rows — without this seam they survive the overwrite and keep enforcing the old branch's rules against
81
87
  * the new content. Optional like {@link listBySession} (a sync-capable backend implements both); the two
82
88
  * bundled impls do. Idempotent: deleting a session with no records is a no-op.
89
+ *
90
+ * @contract session_policy.rev_survives_delete — a key's `rev` (one key = `(sessionId, principal)`, the unit
91
+ * a row is stored under) is a HIGH-WATER MARK that survives this erase: the first `putRules` on that key
92
+ * after a `deleteBySession` stamps a `rev` strictly greater than every rev the key ever carried, so no later
93
+ * row of that key reads a smaller `rev` than an earlier one (another principal's row on the same session
94
+ * counts on its own), and a
95
+ * tighten-only (non-operator) write landing after an erase cannot slip under a floor a reader already
96
+ * enforced. A backend that reads "no row" as rev 0 must therefore keep the mark past the row (the bundled
97
+ * in-memory store and a deployment's SQL twin both do). The erase also ends the row's LINEAGE: the next
98
+ * write on the key carries the next `gen`, so a reader that enforces a row treats a later row of another
99
+ * generation as not comparable whatever its rev — a recreated row cannot pass for a continuation.
83
100
  */
84
101
  deleteBySession?(sessionId: string): Promise<void>;
85
102
  }
@@ -123,6 +140,13 @@ export declare function stripRev(rules: SessionPermissionRules): SessionPermissi
123
140
  */
124
141
  export declare class InMemorySessionPolicyStore implements SessionPolicyStore {
125
142
  private readonly map;
143
+ /**
144
+ * The per-key mark {@link SessionPolicyStore.deleteBySession} may not erase: the largest `rev` this key has
145
+ * ever been stamped with, kept outside the row so removing the row cannot roll the counter back. Written by
146
+ * every stamp, read by the next one, never read by anything else, and never collected — an erase frees the
147
+ * row and keeps this, so the cost is one integer per distinct key the process has ever written.
148
+ */
149
+ private readonly highWater;
126
150
  private key;
127
151
  getRules(sessionId: string, principal?: string): Promise<StoredSessionRules | null>;
128
152
  putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
@@ -76,6 +76,7 @@ export function stripRev(rules) {
76
76
  }
77
77
  export class InMemorySessionPolicyStore {
78
78
  map = new Map();
79
+ highWater = new Map();
79
80
  key(sessionId, principal) {
80
81
  return JSON.stringify([sessionId, principal ?? null]);
81
82
  }
@@ -96,8 +97,12 @@ export class InMemorySessionPolicyStore {
96
97
  throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
97
98
  }
98
99
  }
99
- const next = { ...structuredClone(clean), rev: (prior?.rev ?? 0) + 1 };
100
+ const mark = this.highWater.get(key) ?? { rev: 0, gen: 0 };
101
+ const floor = Math.max(prior?.rev ?? 0, mark.rev);
102
+ const gen = prior?.gen ?? mark.gen;
103
+ const next = { ...structuredClone(clean), rev: floor + 1, gen };
100
104
  this.map.set(key, next);
105
+ this.highWater.set(key, { rev: next.rev, gen });
101
106
  return structuredClone(next);
102
107
  }
103
108
  async listBySession(sessionId) {
@@ -110,10 +115,13 @@ export class InMemorySessionPolicyStore {
110
115
  return out;
111
116
  }
112
117
  async deleteBySession(sessionId) {
113
- for (const k of [...this.map.keys()]) {
118
+ for (const [k, row] of [...this.map.entries()]) {
114
119
  const [sid] = JSON.parse(k);
115
- if (sid === sessionId)
116
- this.map.delete(k);
120
+ if (sid !== sessionId)
121
+ continue;
122
+ const mark = this.highWater.get(k) ?? { rev: 0, gen: 0 };
123
+ this.highWater.set(k, { rev: Math.max(mark.rev, row.rev), gen: mark.gen + 1 });
124
+ this.map.delete(k);
117
125
  }
118
126
  }
119
127
  }
@@ -75,6 +75,16 @@ export async function backgroundAgentStoreContract(mk, runAssertion) {
75
75
  const store = make();
76
76
  await assert.rejects(store.put(record({ scope: "" })), "an empty scope must be refused loudly, not stored");
77
77
  });
78
+ run("a row carrying a key this build does not declare round-trips: no refusal, every declared field intact", async (make) => {
79
+ const store = make();
80
+ await store.put({ ...record({ handle: "a-extra", name: "worker", agentType: "reader" }), teamName: "retired-column" });
81
+ const row = await held(store, "a-extra");
82
+ assert.equal(row.name, "worker");
83
+ assert.equal(row.agentType, "reader");
84
+ assert.equal(row.status, "running");
85
+ await store.update("a-extra", S, { ...row, status: "completed" }, { rev: row.rev });
86
+ assert.equal((await held(store, "a-extra")).status, "completed", "a CAS update over such a row still lands");
87
+ });
78
88
  run("get returns DETACHED copies: mutating a read never reaches the stored row", async (make) => {
79
89
  const store = make();
80
90
  await store.put(record({ handle: "a1", usage: { totalTokens: 5 } }));
@@ -0,0 +1,23 @@
1
+ import { type PeerDirectory, type PeerSessionRecord } from "../../agents/peer-directory.js";
2
+ import { type ContractAssertionRunner } from "./contract-harness.js";
3
+ /** The scope every case registers rows under; the scope-wall case constructs a second one. */
4
+ export declare const PEER_DIRECTORY_CONTRACT_SCOPE = "peer-kit-A";
5
+ export interface PeerDirectoryContractHooks {
6
+ /** Write (or replace) one row through the host's own write protocol. The kit never writes a row
7
+ * the engine's reader would refuse. */
8
+ write: (dir: PeerDirectory, record: PeerSessionRecord) => void | Promise<void>;
9
+ runAssertion?: ContractAssertionRunner;
10
+ }
11
+ /**
12
+ * The directory contract. Four readings, each the reason the engine can do something:
13
+ * 1. **scope wall** — a listing is the ACCESSOR's scope only; the cross-principal wall's mechanical
14
+ * seat is this filter, so a row leaking across scopes is a tenant leak, not a display bug;
15
+ * 2. **the full set, copies** — live, dead AND deleted rows are all listed (a `name [ref]` is minted
16
+ * over the full set; a dead row is the name truth offline delivery resolves through; a deleted
17
+ * row resolves only to refuse), and a listed row is a COPY (mutating it changes no later listing);
18
+ * 3. **resolution feeds on the listing** — the engine's own resolver, over the rows as listed, finds
19
+ * a live row by name, still finds a dead one, and answers `deleted` for a tombstone;
20
+ * 4. **restore is CAS-on-absent** (only where the host implements it) — an absent row is restored and
21
+ * then listed; a present row is never overwritten (`false`, the existing row byte-unchanged).
22
+ */
23
+ export declare function peerDirectoryContract(mk: () => PeerDirectory, hooks: PeerDirectoryContractHooks): Promise<void>;
@@ -0,0 +1,82 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { resolvePeerSessions } from "../../agents/peer-directory.js";
3
+ import { beginContract } from "./contract-harness.js";
4
+ export const PEER_DIRECTORY_CONTRACT_SCOPE = "peer-kit-A";
5
+ function row(name, sessionId, over = {}) {
6
+ return { schemaVersion: 1, sessionId, scope: PEER_DIRECTORY_CONTRACT_SCOPE, name, startedAt: 1_000, updatedAt: 2_000, liveness: "live", ...over };
7
+ }
8
+ async function withDirs(mk, fn) {
9
+ const live = [];
10
+ const make = () => {
11
+ const d = mk();
12
+ live.push(d);
13
+ return d;
14
+ };
15
+ try {
16
+ await fn(make);
17
+ }
18
+ finally {
19
+ for (const d of live.splice(0))
20
+ d.close?.();
21
+ }
22
+ }
23
+ export async function peerDirectoryContract(mk, hooks) {
24
+ const { run: runRaw, settle } = beginContract(hooks.runAssertion);
25
+ const run = (name, fn) => runRaw(name, () => withDirs(mk, fn));
26
+ const S = PEER_DIRECTORY_CONTRACT_SCOPE;
27
+ run("scope wall: a listing is the accessor's scope only", async (make) => {
28
+ const d = make();
29
+ await hooks.write(d, row("alpha", "sess-a"));
30
+ await hooks.write(d, row("beta", "sess-b", { scope: "peer-kit-B" }));
31
+ const inA = await d.listPeerSessions({ scope: S });
32
+ assert.deepEqual(inA.map((r) => r.sessionId), ["sess-a"], "scope A must list exactly its own row");
33
+ const inB = await d.listPeerSessions({ scope: "peer-kit-B" });
34
+ assert.deepEqual(inB.map((r) => r.sessionId), ["sess-b"]);
35
+ const inC = await d.listPeerSessions({ scope: "peer-kit-C" });
36
+ assert.equal(inC.length, 0, "an unrelated scope sees nothing");
37
+ });
38
+ run("the full set is listed (live + dead + deleted) and every listed row is a copy", async (make) => {
39
+ const d = make();
40
+ await hooks.write(d, row("alpha", "sess-a"));
41
+ await hooks.write(d, row("beta", "sess-b", { liveness: "dead", diedAt: 3_000 }));
42
+ await hooks.write(d, row("gamma", "sess-c", { liveness: "deleted", diedAt: 3_000 }));
43
+ const listed = await d.listPeerSessions({ scope: S });
44
+ assert.deepEqual([...listed.map((r) => r.sessionId)].sort(), ["sess-a", "sess-b", "sess-c"], "dead and deleted rows are MARKED, never dropped from a listing");
45
+ assert.deepEqual(Object.fromEntries(listed.map((r) => [r.sessionId, r.liveness])), { "sess-a": "live", "sess-b": "dead", "sess-c": "deleted" });
46
+ const first = listed.find((r) => r.sessionId === "sess-a");
47
+ first.name = "scribbled";
48
+ const again = await d.listPeerSessions({ scope: S });
49
+ assert.equal(again.find((r) => r.sessionId === "sess-a").name, "alpha", "a listed row is a copy");
50
+ });
51
+ run("the engine's resolver over the listing: live by name, dead still found, deleted refuses", async (make) => {
52
+ const d = make();
53
+ await hooks.write(d, row("alpha", "sess-a"));
54
+ await hooks.write(d, row("beta", "sess-b", { liveness: "dead", diedAt: 3_000 }));
55
+ await hooks.write(d, row("gamma", "sess-c", { liveness: "deleted", diedAt: 3_000 }));
56
+ const rows = await d.listPeerSessions({ scope: S });
57
+ const alpha = resolvePeerSessions(rows, "alpha");
58
+ assert.equal(alpha.status, "found");
59
+ const beta = resolvePeerSessions(rows, "beta");
60
+ assert.equal(beta.status, "found", "a dead row is the name truth offline delivery resolves through");
61
+ const gamma = resolvePeerSessions(rows, "gamma");
62
+ assert.equal(gamma.status, "deleted", "a tombstone resolves only to refuse");
63
+ assert.equal(resolvePeerSessions(rows, "session.sess-b").status, "found", "an address resolves to a KNOWN row");
64
+ assert.equal(resolvePeerSessions(rows, "session.sess-zzz").status, "not_found", "an address nobody registered is not an append target");
65
+ });
66
+ run("restorePeerSession (where implemented) is CAS-on-absent: restores an absent row, never overwrites a present one", async (make) => {
67
+ const d = make();
68
+ if (d.restorePeerSession === undefined)
69
+ return;
70
+ const restored = await d.restorePeerSession(row("alpha", "sess-a", { liveness: "dead", diedAt: 3_000 }));
71
+ assert.equal(restored, true, "an absent row is restored");
72
+ assert.deepEqual((await d.listPeerSessions({ scope: S })).map((r) => r.sessionId), ["sess-a"]);
73
+ await hooks.write(d, row("alpha", "sess-a", { updatedAt: 9_000 }));
74
+ const again = await d.restorePeerSession(row("alpha-stale", "sess-a", { liveness: "dead", diedAt: 3_000 }));
75
+ assert.equal(again, false, "a present row wins over a restore");
76
+ const current = (await d.listPeerSessions({ scope: S })).find((r) => r.sessionId === "sess-a");
77
+ assert.equal(current.name, "alpha");
78
+ assert.equal(current.liveness, "live");
79
+ assert.equal(current.updatedAt, 9_000, "the existing row is byte-unchanged by a losing restore");
80
+ });
81
+ await settle();
82
+ }
@@ -883,6 +883,12 @@ export type TaskEvent = ({
883
883
  * nor an agent-type — the child then keeps its taskId (NOT the raw objective, which could leak a delegated
884
884
  * secret to a progress-scoped consumer — dual-review Q2). Sanitized (control-char-stripped, length-capped). */
885
885
  name?: string;
886
+ /** The EXPLICIT `Agent({name})` spawn identity of the sub-run, when the spawn carried one — the
887
+ * teammate KEY, as opposed to {@link name} (the display label): the same string the fleet row's
888
+ * spawn frame (`BackgroundChildEvent.spawnName`), the roster row and the child's uplink `from`
889
+ * carry, so a consumer binds "@name → row" by string equality. Validated at spawn
890
+ * (`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`). ABSENT = an unnamed delegation, never a missing value. */
891
+ spawnName?: string;
886
892
  /**
887
893
  * The model this sub-run was PREPARED with — the resolved id, read off the leg's prepared model
888
894
  * at the mint, so it is the same value `TaskResult.model` and the `task.start` trace frame carry.
@@ -1,7 +1,25 @@
1
+ import type { MailboxAppendMessage, MailboxStore } from "./mailbox-store.js";
1
2
  /** design/135 G2: `"event"` is the ONE non-terminal member — a Monitor batch notification (new stdout
2
3
  * lines from a still-running watch). Everything else is a terminal completion notification. */
3
4
  export type TaskNotificationStatus = "completed" | "failed" | "killed" | "cancelled" | "event";
4
5
  export type SystemInjectionPriority = "now" | "next" | "later";
6
+ /**
7
+ * The outcome an injection entry settles with — what the session did with the frame:
8
+ * · `queued` — a live lane took it (it rides that run's next turn boundary; accepted, not yet consumed);
9
+ * · `parked` — no live lane; pended IN PROCESS on {@link PendingSessionNotifications} for the
10
+ * session's next run in this process (a process restart loses it);
11
+ * · `parked_durably` — no live lane; appended to the session's DURABLE box
12
+ * ({@link PendingSessionNotifications.bindSessionBox}) — the next run drains it, in any process;
13
+ * · `dropped_duplicate` — the session already delivered this exact frame.
14
+ * The park backend is the seat's choice ({@link routeOrParkNotification}), never the producer's.
15
+ */
16
+ export type NotificationInjectionOutcome = "queued" | "parked" | "parked_durably" | "dropped_duplicate";
17
+ export declare const NOTIFICATION_INJECTION_OUTCOMES: readonly ["queued", "parked", "parked_durably", "dropped_duplicate"];
18
+ /** Is `value` one of the injector's outcome words? A host-wired uplink may answer anything (a bare
19
+ * `void`, an array's `push` count) — such an answer reads as "outcome not reported", never as a word. */
20
+ export declare function isNotificationInjectionOutcome(value: unknown): value is NotificationInjectionOutcome;
21
+ /** The disposition a park/route decision reports back — the injection outcome minus the dedup fold. */
22
+ export type NotificationDisposition = Exclude<NotificationInjectionOutcome, "dropped_duplicate">;
5
23
  /** The closed value set of {@link SystemInjectionPriority}, as data — the wire-facing entries validate
6
24
  * against THIS rather than against three inline string literals, so widening the type widens the guard.
7
25
  * (Names taken verbatim from CC 2.1.223's `UYo` @ `CC:644918`; see the semantics note on
@@ -135,6 +153,12 @@ export interface TaskNotificationPayload {
135
153
  * outside caller can neither mint a chain nor wear the peer class. */
136
154
  peer?: {
137
155
  hopChain: string[];
156
+ /** The SENDING run's session id, when the producer knows it — the reply/receipt address a durable
157
+ * park of this frame records (`MailboxPeerMeta.fromSession`). Engine-read; never rendered. */
158
+ fromSession?: string;
159
+ /** The sender's admission key (the same token the send-point gate charged), so a durable park
160
+ * re-judges the frame under the same sender at the drain. Engine-read; never rendered. */
161
+ senderKey?: string;
138
162
  };
139
163
  /**
140
164
  * design/385 §1.4 d1 — the delegated-child → parent UPLINK carrier (`SendMessage("main")`). Its
@@ -264,7 +288,7 @@ export interface SystemInjection<TPayload = unknown> {
264
288
  * listener: "queued" once the boundary steer accepted, "parked" when the idle tail-race (or the
265
289
  * engine-note backlog cap) rejected and the payload was pended per session. Absent =
266
290
  * fire-and-forget producer (no receipt to honor). */
267
- onDisposition?: (d: "queued" | "parked") => void;
291
+ onDisposition?: (d: NotificationDisposition) => void;
268
292
  }
269
293
  /** design/373 (adversarial r3, recorded rule): the key deliberately EXCLUDES the injection tier.
270
294
  * The key names an EVENT's identity; `priority` is delivery metadata about one submission of it.
@@ -356,6 +380,27 @@ export declare function renderTaskNotificationXml(n: TaskNotificationPayload): s
356
380
  export declare const MAX_PENDING_EVENTS_PER_TASK = 50;
357
381
  export declare const MAX_PENDING_EVENTS_PER_SESSION = 200;
358
382
  export declare const MAX_PENDING_SESSIONS = 100;
383
+ /** Session-box bindings are three references and a handle; bounded far above the parked-session cap so
384
+ * a long-lived leader's binding is not evicted by parked traffic (eviction = the park backend degrades
385
+ * to in-process, which the receipt then says). */
386
+ export declare const MAX_SESSION_BOX_BINDINGS = 4096;
387
+ /**
388
+ * A session's DURABLE box, bound to the park seat by the leg that mounts its session-box drain — one
389
+ * mint (the drain mount judgment), two readers (the drain and {@link routeOrParkNotification}), so
390
+ * "a frame parked here is drained there" holds by construction. Absent for a session whose legs mount
391
+ * no drain (a delegated child, an ungrammatical id, no cross-process-safe mailbox).
392
+ */
393
+ export interface SessionBoxBinding {
394
+ mailbox: MailboxStore;
395
+ /** The registry/mailbox scope the session's legs mount in (the drain reads the same scope). */
396
+ scope: string;
397
+ /** The box handle (`session.<id>`), minted after the address grammar passed. */
398
+ handle: string;
399
+ /** The session id the box belongs to — the `fromRoot` a durably parked child frame records. */
400
+ sessionId: string;
401
+ /** Where an append fault is disclosed (the frame then takes the in-process park — never lost). */
402
+ onError?: (e: unknown) => void;
403
+ }
359
404
  /**
360
405
  * RB-375 A2 (ruled 2026-08-03) — is this frame the TERMINAL of a delegated background agent?
361
406
  *
@@ -423,7 +468,7 @@ export interface DrainedPendingNotifications {
423
468
  * · `"unavailable"` — the lane can no longer take anything; the caller tries the next lane, and
424
469
  * finally the parked store.
425
470
  */
426
- export type LiveNotificationLane = (payload: TaskNotificationPayload, priority: SystemInjectionPriority, onDisposition?: (d: "queued" | "parked") => void) => "taken" | "pending" | "duplicate" | "unavailable";
471
+ export type LiveNotificationLane = (payload: TaskNotificationPayload, priority: SystemInjectionPriority, onDisposition?: (d: NotificationDisposition) => void) => "taken" | "pending" | "duplicate" | "unavailable";
427
472
  /** One run's seat: its two entry points, plus the two bookkeeping verbs its owner drives. */
428
473
  export interface LiveNotificationLaneSeat {
429
474
  /**
@@ -500,7 +545,7 @@ export declare class SessionLiveNotificationLanes {
500
545
  * special-cased: a lane that just refused this frame reports `"unavailable"` for it from then on.
501
546
  */
502
547
  deliver(sessionId: string, payload: TaskNotificationPayload, priority: SystemInjectionPriority, opts?: {
503
- onDisposition?: (d: "queued" | "parked") => void;
548
+ onDisposition?: (d: NotificationDisposition) => void;
504
549
  }): "taken" | "pending" | "duplicate" | "unavailable";
505
550
  /** Sessions holding at least one live lane (observability + the tests' rollback detector). */
506
551
  get size(): number;
@@ -528,6 +573,16 @@ export declare class PendingSessionNotifications {
528
573
  /** Whole-session losses whose tombstone was itself evicted — the count survives, the attribution
529
574
  * does not. Surfaced on the next session-level disclosure so it is never simply forgotten. */
530
575
  private unattributedDrops;
576
+ /** The durable park backend per session ({@link SessionBoxBinding}); insertion-ordered, bounded. */
577
+ private readonly sessionBoxes;
578
+ /**
579
+ * Bind (or, with `undefined`, unbind) the session's durable box. Written at EVERY drain mount of a
580
+ * session's leg, so the binding tracks the deployment's current seats (a mailbox swapped out
581
+ * unbinds at the next mount). A re-bind refreshes the entry's LRU position.
582
+ */
583
+ bindSessionBox(sessionId: string, box: SessionBoxBinding | undefined): void;
584
+ /** The session's durable box, when a leg of it mounted the drain in this process. */
585
+ sessionBoxFor(sessionId: string): SessionBoxBinding | undefined;
531
586
  pend(sessionId: string, n: TaskNotificationPayload, priority?: SystemInjectionPriority): void;
532
587
  /** Remove and return the session's pendings (one-shot — the next run consumes them exactly once).
533
588
  * design/373 §3.5: the batch comes out PRIORITY-MAJOR (stable sort — pend order within a class;
@@ -548,6 +603,13 @@ export declare class PendingSessionNotifications {
548
603
  * next drain. Callers ask BEFORE routing and BEFORE parking.
549
604
  */
550
605
  holdsKey(sessionId: string, dedupKey: string): boolean;
606
+ /** Keys the session's DURABLE box took ({@link routeOrParkNotification}'s durable arm) — the third holder
607
+ * {@link holdsKey} consults, so a retry of a frame the box already holds folds exactly as one the
608
+ * in-process store holds. Bounded like {@link routed}; a key is released when the append faulted and
609
+ * the frame took the in-process park instead (that store then owns it). */
610
+ private readonly durable;
611
+ noteDurablePark(sessionId: string, dedupKey: string): void;
612
+ forgetDurablePark(sessionId: string, dedupKey: string): void;
551
613
  /** Record that this session has DELIVERED this frame — see {@link routed}. Written at ONE place, the
552
614
  * harness's consumption sink (the step where a queued frame enters the model's input), so the record
553
615
  * can never run ahead of the delivery it claims. */
@@ -565,7 +627,20 @@ export declare class PendingSessionNotifications {
565
627
  * ({@link LiveNotificationLaneSeat.markDelivered});
566
628
  * · nothing can take it ⇒ park, carrying the tier, for the session's next run.
567
629
  */
568
- export declare function routeOrParkNotification(store: PendingSessionNotifications, sessionId: string, payload: TaskNotificationPayload, priority: SystemInjectionPriority, onDisposition?: (d: "queued" | "parked") => void): void;
630
+ export declare function routeOrParkNotification(store: PendingSessionNotifications, sessionId: string, payload: TaskNotificationPayload, priority: SystemInjectionPriority, onDisposition?: (d: NotificationDisposition) => void): void;
631
+ /**
632
+ * The durable form of a parked peer-words frame: the SAME `peer_message` record the cross-session lane
633
+ * appends (the recipient's drain renders it through the one envelope and judges it through the one
634
+ * admission gate), with `fromRoot` = the box owner's own session — a frame reaches this seat only
635
+ * through the injector its owner handed to a child of its tree, so the same-tree fact is the seat's to
636
+ * state, not the producer's to claim.
637
+ */
638
+ export declare function sessionBoxRecordOf(payload: TaskNotificationPayload & {
639
+ agentMessage: {
640
+ from: string;
641
+ body: string;
642
+ };
643
+ }, box: Pick<SessionBoxBinding, "sessionId">): MailboxAppendMessage;
569
644
  /**
570
645
  * Delivery-side overflow disclosure: fold the per-task drop counts into the drained payloads. Two
571
646
  * carrier shapes, chosen per survivor: