@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
@@ -150,6 +150,30 @@ export interface RuleAddDelta {
150
150
  add: RuleAdd;
151
151
  redemption: RedemptionAuthorization;
152
152
  }
153
+ /**
154
+ * The TIGHTENING add — the true dual of {@link RuleDeleteDelta}, and the reason it is a separate arm
155
+ * rather than a `redemption-add` with the authorization left out: a delta named for a redemption that
156
+ * carries no redemption is a name that lies. This arm carries NO authorization block at all, exactly as
157
+ * `tighten-delete` does, because it needs none: a host may narrow on a person's behalf, it may not
158
+ * widen — so `behavior` excludes `allow` IN THE TYPE and the widening direction cannot reach this door.
159
+ * Minting a standing approval still takes a person's consent record, which is what `redemption-add` is.
160
+ *
161
+ * The QUARANTINE fence ({@link assertRedemptionNotQuarantined}) applies to `redemption-add` only, and
162
+ * not because it was forgotten here: the fence exists to stop a REPLAYED approval record from reviving
163
+ * an add its dot was quarantined for. A tightening add has no record and is never replayed — its dot is
164
+ * minted once, at the call — so there is no replay for the fence to catch.
165
+ */
166
+ export interface RuleTightenAddDelta {
167
+ kind: "tighten-add";
168
+ /** The tightening members only. `allow` is not in the type: see this interface's doc. */
169
+ behavior: Exclude<RuleBehavior, "allow">;
170
+ rule: string;
171
+ scope: RuleScope;
172
+ tool: PersistedRule["tool"];
173
+ match: PersistedRule["match"];
174
+ command: string;
175
+ add: RuleAdd;
176
+ }
153
177
  /** The delete half: appending one tombstone, and nothing else. */
154
178
  export interface RuleDeleteDelta {
155
179
  kind: "tighten-delete";
@@ -196,7 +220,7 @@ export interface RuleSyncJoinDelta {
196
220
  * again AT THE BACKEND: the fifth door of design/179 §4's validator list closes here, not at the
197
221
  * calling layer, and closes identically for every caller.
198
222
  */
199
- export type RuleWriteDelta = RuleAddDelta | RuleDeleteDelta | RuleSyncJoinDelta;
223
+ export type RuleWriteDelta = RuleAddDelta | RuleTightenAddDelta | RuleDeleteDelta | RuleSyncJoinDelta;
200
224
  /**
201
225
  * The backend write face. Exported as part of the BACKEND CONTRACT (ruled 2026-08-10) so an
202
226
  * out-of-repo store implementation hangs the same face the file backend does, instead of mirroring the
@@ -269,7 +293,7 @@ export declare function sameRuleIdentity(a: {
269
293
  export declare function applyTombstones(rules: readonly PersistedRule[], tombstones: readonly RuleTombstone[]): PersistedRule[];
270
294
  /** Fold one delta into a raw rule set, returning the new set. Pure — the backends share it so the file and
271
295
  * in-memory forms cannot drift in what a delta MEANS. */
272
- export declare function foldDelta(rules: readonly PersistedRule[], delta: RuleAddDelta): PersistedRule[];
296
+ export declare function foldDelta(rules: readonly PersistedRule[], delta: RuleAddDelta | RuleTightenAddDelta): PersistedRule[];
273
297
  /** Every add dot present in a rule set. */
274
298
  export declare function addDotsOf(rules: readonly PersistedRule[]): RuleDot[];
275
299
  /**
@@ -307,7 +331,7 @@ export declare function assertDeleteDeltaCarriesNoAdd(delta: RuleDeleteDelta): v
307
331
  * any lane — host-side import doors included — that writes rows into a persisted store carries the
308
332
  * durable two-member face.
309
333
  */
310
- export declare function assertWriteDeltaScopeDurable(delta: RuleAddDelta | RuleDeleteDelta): void;
334
+ export declare function assertWriteDeltaScopeDurable(delta: RuleAddDelta | RuleTightenAddDelta | RuleDeleteDelta): void;
311
335
  /**
312
336
  * The state one sync round exchanges: raw adds (tombstoned dots NOT pre-filtered) plus tombstones.
313
337
  * The live view is always DERIVED (`applyTombstones`); the join below never deletes anything.
@@ -497,6 +521,16 @@ export declare function applySyncJoin(cur: RuleSyncJoinBase, delta: RuleSyncJoin
497
521
  export declare function assertRedemptionNotQuarantined(quarantined: readonly QuarantinedRuleAdd[], delta: RuleAddDelta): void;
498
522
  /** Integrity fingerprint over a store's own facts. Corruption detection, not tamper-proofing. */
499
523
  export declare function ruleStoreChecksum(payload: unknown): Promise<string>;
524
+ /**
525
+ * What a removal may say about the rule identity it just tombstoned. THREE words, not a boolean, for the
526
+ * same reason {@link RuleAddLanding} has `committed: "no" | "unknown"`: the answer comes from a read-back,
527
+ * and a read-back can fail. `yes` and `no` are things the store said; `unknown` is the honest word for a
528
+ * look that did not happen — reporting `no` there would render "we could not see it" as "it is not there",
529
+ * which is the one sentence this domain refuses to say anywhere else ("we cannot see the rules" is never
530
+ * "there are none").
531
+ */
532
+ export declare const REMOVAL_LIVENESS_WORDS: readonly ["yes", "no", "unknown"];
533
+ export type RemovalLiveness = (typeof REMOVAL_LIVENESS_WORDS)[number];
500
534
  /**
501
535
  * The result of a removal.
502
536
  *
@@ -506,19 +540,26 @@ export declare function ruleStoreChecksum(payload: unknown): Promise<string>;
506
540
  export type RemoveResult =
507
541
  /** The tombstone landed and none of the observed dots survive. `stillLive` reports whether an approval
508
542
  * that landed DURING this call keeps the rule alive anyway (add-wins) — a real outcome with a name,
509
- * not an anomaly. */
543
+ * not an anomaly — or {@link RemovalLiveness} `unknown` when the read-back that answers it could not be
544
+ * taken. A consumer reading `unknown` as "removed" has re-introduced the very over-claim the third word
545
+ * exists to prevent: the tombstone landed, the rule's liveness is UNOBSERVED, reconcile with `list()`. */
510
546
  {
511
547
  status: "removed";
512
548
  rev: number;
513
- stillLive: boolean;
549
+ stillLive: RemovalLiveness;
514
550
  }
515
551
  /** Nothing to remove — including a repeated delete. No empty tombstone is minted. */
516
552
  | {
517
553
  status: "no-op";
518
554
  rev: number;
519
555
  }
520
- /** Terminal failure, confirmed by read-back to have written NOTHING. When the read-back itself failed,
521
- * `error` says so and the caller reconciles with `list()`. */
556
+ /** Terminal failure, in one of two strengths the `error` text distinguishes — word for word the dual of
557
+ * {@link AddResult}'s. When the STORE answered every attempt (the compare-and-set simply lost), the
558
+ * read-back confirms nothing was written and the text says so plainly. When the apply THREW, a missing
559
+ * tombstone cannot tell a write that never landed from one whose evidence a paired collection erased
560
+ * (`collectBelowFrontier` recycles a tombstone together with the adds it covered), so that outcome is
561
+ * reported INDETERMINATE and the caller reconciles with `list()` rather than retrying blind. When the
562
+ * read-back itself failed, `error` says so and the caller reconciles with `list()` too. */
522
563
  | {
523
564
  status: "failed";
524
565
  error: string;
@@ -544,6 +585,101 @@ export declare function removePersistedRule(opts: {
544
585
  principal: string | RuleOwner;
545
586
  provider: import("./permission-rule-provider.js").PermissionRuleStoreProvider;
546
587
  }): Promise<RemoveResult>;
588
+ /**
589
+ * What the ONE add-write path answered. `landed` carries the revision the add committed at; every other
590
+ * terminal state is a single refusal string, which each door renders in its own vocabulary (the consent
591
+ * protocol says `refused`, the record-less entry says `failed`) — one mechanism, two spellings, never
592
+ * two mechanisms.
593
+ */
594
+ export type RuleAddLanding = {
595
+ landed: true;
596
+ rev: number;
597
+ }
598
+ /** `committed` is what the caller may CLAIM afterwards. `"no"` means the store itself answered — a
599
+ * read that failed before any write, a refused preflight, an epoch seal, or a compare-and-set that
600
+ * lost every attempt: each of those is the store saying nothing landed. `"unknown"` means the apply
601
+ * THREW, which proves nothing either way: a backend can commit and then lose its acknowledgement. */
602
+ | {
603
+ landed: false;
604
+ reason: string;
605
+ committed: "no" | "unknown";
606
+ };
607
+ /**
608
+ * Land ONE add — the terminal write both doors take (the consent protocol's redemption and the
609
+ * record-less tightening entry below). Optimistic: every attempt re-reads the store, anchors the
610
+ * compare-and-set to that revision, and retries a conflict without rebuilding the add (the dot is the
611
+ * add's identity and is minted once, by the caller, so a retry can never mint a second).
612
+ *
613
+ * The scope routing is the writer's ({@link writerOf}): a session-scope add lands in the session
614
+ * partition, everything else in the durable one — one path, so the two doors cannot disagree about
615
+ * where a rule goes.
616
+ */
617
+ export declare function landRuleAdd(args: {
618
+ store: import("./permission-rule-provider.js").PermissionRuleStore;
619
+ writer: PermissionRuleWriter;
620
+ /** Either add arm, whole. The kinds are dispatched EXHAUSTIVELY below, so a third add arm cannot be
621
+ * added to the union without this function being told what it means. */
622
+ delta: RuleAddDelta | RuleTightenAddDelta;
623
+ /** Judged on EVERY attempt's snapshot — the very revision the write below is anchored to, so a fact
624
+ * that changed in between conflicts the write and is re-judged. A returned string refuses. */
625
+ preflight?: (snapshot: import("./permission-rule-provider.js").EffectivePermissionRules) => Promise<string | undefined>;
626
+ }): Promise<RuleAddLanding>;
627
+ /**
628
+ * The result of an add — the dual of {@link RemoveResult}, word for word where the two mean the same
629
+ * thing. `conflict` is not a member for the same reason: the retry loop is inside.
630
+ */
631
+ export type AddResult =
632
+ /** The add landed; `dot` is its audit identity (the same value a removal's tombstone will name).
633
+ * Deliberately WITHOUT a `stillLive` twin of {@link RemoveResult}: a removal's twin reports the
634
+ * semilattice's own add-wins resolution, a structural outcome of the write; a fresh dot has no such
635
+ * resolution to report, and a removal that lands afterwards is an ordinary later event `list()`
636
+ * answers. */
637
+ {
638
+ status: "added";
639
+ rev: number;
640
+ dot: RuleDot;
641
+ }
642
+ /** An equivalent rule is already live in this bucket — the instruction is already in effect, and no
643
+ * second dot is minted. Repeating the call is a no-op, exactly as repeating a delete is. */
644
+ | {
645
+ status: "no-op";
646
+ rev: number;
647
+ }
648
+ /** Terminal failure, in one of two strengths the `error` text distinguishes. When the STORE answered
649
+ * (a read that failed before any write, a refused preflight, an epoch seal, a compare-and-set that
650
+ * lost every attempt) nothing landed and the text says so plainly. When the apply THREW, the write
651
+ * may have committed and lost its acknowledgement: the read-back asks the raw face, where an add
652
+ * survives the tombstone a concurrent removal laid on it — but a sync collection can erase even
653
+ * that, so a dot it cannot find is reported INDETERMINATE rather than as a confirmed nothing, and
654
+ * the caller reconciles with `list()` instead of retrying blind (a blind retry mints a fresh dot and
655
+ * can reinstate a restriction somebody had just removed). */
656
+ | {
657
+ status: "failed";
658
+ error: string;
659
+ };
660
+ /**
661
+ * Add a persisted rule in ONE step, with no approval record.
662
+ *
663
+ * The dual of {@link removePersistedRule}, and bounded by the same asymmetry: a host may narrow on a
664
+ * person's behalf, it may not widen. So this entry writes the TIGHTENING direction only — `deny` and
665
+ * `ask` — and `allow` is not spellable here at all (minting a standing approval still takes a person's
666
+ * consent record: prepare → confirm → redeem). A JS caller that spells it anyway meets a loud refusal.
667
+ *
668
+ * The provenance is MINTED HERE as `user`: this entry takes no origin argument, so a host cannot claim
669
+ * a rule came from an import or a starter set. Before it existed, the only way to land a rule was to
670
+ * borrow the approval-record legs, which stamped the borrowed record's kind onto a write nobody had
671
+ * approved — a second, unverified story about where a rule came from.
672
+ */
673
+ export declare function addPersistedRule(opts: {
674
+ /** The identity's behavior member. `allow` is deliberately not in the type — see the entry's doc. */
675
+ behavior: Exclude<RuleBehavior, "allow">;
676
+ rule: string;
677
+ scope: RuleScope;
678
+ /** Whose bucket — the same two spellings {@link removePersistedRule} takes: a bare string is the
679
+ * principal shorthand, the structural {@link RuleOwner} adds the local-owner bucket. */
680
+ principal: string | RuleOwner;
681
+ provider: import("./permission-rule-provider.js").PermissionRuleStoreProvider;
682
+ }): Promise<AddResult>;
547
683
  export declare function errText(err: unknown): string;
548
684
  /** A durable partition that always reports zero rules — what an unauthenticated principal resolves to. */
549
685
  export declare const EMPTY_DURABLE_RULE_PARTITION: DurableRulePartition;
@@ -56,19 +56,19 @@ export function assertDeleteDeltaCarriesNoAdd(delta) {
56
56
  }
57
57
  }
58
58
  export function assertWriteDeltaScopeDurable(delta) {
59
- const scope = delta.kind === "redemption-add" ? delta.scope : delta.tombstone?.scope;
59
+ const scope = delta.kind === "tighten-delete" ? delta.tombstone?.scope : delta.scope;
60
60
  if (scope?.kind === "session") {
61
- const e = new Error(delta.kind === "redemption-add"
62
- ? "a session-scope rule cannot enter the persisted store — its home is the session's own overlay (design/382 §4.3); refusing the redemption-add"
63
- : "a session-scope tombstone cannot enter the persisted store — session rows are not individually deletable and die with their session (design/382 §4.3/§10); refusing the tighten-delete");
61
+ const e = new Error(delta.kind === "tighten-delete"
62
+ ? "a session-scope tombstone cannot enter the persisted store — session rows are not individually deletable and die with their session (design/382 §4.3/§10); refusing the tighten-delete"
63
+ : `a session-scope rule cannot enter the persisted store — its home is the session's own overlay (design/382 §4.3); refusing the ${delta.kind}`);
64
64
  e.code = "unsupported.session_scope_store";
65
65
  throw e;
66
66
  }
67
67
  if (!isValidDurableScope(scope)) {
68
- throw new Error(delta.kind === "redemption-add"
69
- ? "a redemption-add whose scope is not a well-formed durable scope cannot enter the persisted store (design/382 §4.3: the face is {global, project-with-a-root}) — refusing the write"
70
- :
71
- "a tighten-delete must carry a tombstone whose scope is a well-formed durable scope (design/382 §4.3: the face is {global, project-with-a-root}) — refusing the write");
68
+ throw new Error(delta.kind === "tighten-delete"
69
+ ?
70
+ "a tighten-delete must carry a tombstone whose scope is a well-formed durable scope (design/382 §4.3: the face is {global, project-with-a-root}) — refusing the write"
71
+ : `a ${delta.kind} whose scope is not a well-formed durable scope cannot enter the persisted store (design/382 §4.3: the face is {global, project-with-a-root}) — refusing the write`);
72
72
  }
73
73
  }
74
74
  export function isValidConsentScope(scope) {
@@ -393,6 +393,7 @@ export async function ruleStoreChecksum(payload) {
393
393
  const { createHash } = await import("node:crypto");
394
394
  return `sha256:${createHash("sha256").update(canonicalize(payload), "utf8").digest("hex")}`;
395
395
  }
396
+ export const REMOVAL_LIVENESS_WORDS = ["yes", "no", "unknown"];
396
397
  const REMOVE_MAX_ATTEMPTS = 8;
397
398
  export async function removePersistedRule(opts) {
398
399
  const behavior = opts.behavior;
@@ -435,6 +436,7 @@ export async function removePersistedRule(opts) {
435
436
  const tombstone = { behavior, rule, scope, removedDots, deletedBy: await writer.nextDot() };
436
437
  let expectedRev = snapshot.rev;
437
438
  let lastError = "";
439
+ let applyThrew = false;
438
440
  for (let attempt = 0; attempt < REMOVE_MAX_ATTEMPTS; attempt++) {
439
441
  try {
440
442
  const res = await writer.apply({ kind: "tighten-delete", tombstone }, { expectedRev });
@@ -442,37 +444,181 @@ export async function removePersistedRule(opts) {
442
444
  return { status: "failed", error: `the store refused the tombstone (${res.refused}) — nothing was removed` };
443
445
  }
444
446
  if (!("conflict" in res)) {
445
- let stillLive = false;
447
+ let stillLive;
446
448
  try {
447
449
  const after = await store.effective();
448
450
  if (after.unreadable !== undefined)
449
451
  throw new Error(after.unreadable);
450
- stillLive = after.rules.some((r) => sameRuleIdentity(r, identity) && isRuleLive(r));
452
+ stillLive = after.rules.some((r) => sameRuleIdentity(r, identity) && isRuleLive(r)) ? "yes" : "no";
451
453
  }
452
454
  catch {
453
- stillLive = false;
455
+ stillLive = "unknown";
454
456
  }
455
457
  return { status: "removed", rev: res.rev, stillLive };
456
458
  }
457
459
  expectedRev = res.rev;
458
460
  }
459
461
  catch (err) {
462
+ applyThrew = true;
460
463
  lastError = errText(err);
461
464
  break;
462
465
  }
463
466
  }
464
- const reason = lastError !== "" ? lastError : `optimistic-concurrency retries exhausted after ${REMOVE_MAX_ATTEMPTS} attempts`;
467
+ const reason = applyThrew
468
+ ? lastError !== ""
469
+ ? lastError
470
+ : "the permission-rule store threw while applying the tombstone, with no message"
471
+ : `optimistic-concurrency retries exhausted after ${REMOVE_MAX_ATTEMPTS} attempts`;
465
472
  try {
466
473
  const after = await writer.readRaw();
467
474
  const landed = after.tombstones.some((t) => t.deletedBy.actor === tombstone.deletedBy.actor && t.deletedBy.counter === tombstone.deletedBy.counter);
468
- return landed
469
- ? { status: "removed", rev: after.rev, stillLive: applyTombstones(after.rules, after.tombstones).some((r) => sameRuleIdentity(r, identity) && isRuleLive(r)) }
470
- : { status: "failed", error: `${reason} (read-back confirms no tombstone was written)` };
475
+ if (landed) {
476
+ return { status: "removed", rev: after.rev, stillLive: applyTombstones(after.rules, after.tombstones).some((r) => sameRuleIdentity(r, identity) && isRuleLive(r)) ? "yes" : "no" };
477
+ }
478
+ return {
479
+ status: "failed",
480
+ error: applyThrew
481
+ ? `${reason}; the store retains no tombstone for this removal, which cannot tell a write that never landed from one whose evidence was collected — the outcome is indeterminate, reconcile with list()`
482
+ : `${reason} (read-back confirms no tombstone was written)`,
483
+ };
471
484
  }
472
485
  catch (err) {
473
486
  return { status: "failed", error: `${reason}; the read-back also failed (${errText(err)}) so this outcome is indeterminate — reconcile with list()` };
474
487
  }
475
488
  }
489
+ const RULE_ADD_MAX_ATTEMPTS = 8;
490
+ const SESSION_ENDED_REFUSAL = "the session this authorization targets has ended — a session rule dies with its session, and a late or replayed redemption cannot revive it (re-trigger the command in a live session for a fresh card)";
491
+ export async function landRuleAdd(args) {
492
+ switch (args.delta.kind) {
493
+ case "redemption-add":
494
+ case "tighten-add":
495
+ break;
496
+ default: {
497
+ const unreachable = args.delta;
498
+ throw new Error(`landRuleAdd: unhandled add delta ${JSON.stringify(unreachable)}`);
499
+ }
500
+ }
501
+ for (let attempt = 0; attempt < RULE_ADD_MAX_ATTEMPTS; attempt++) {
502
+ let rev;
503
+ try {
504
+ const snapshot = await args.store.effective();
505
+ if (snapshot.unreadable !== undefined)
506
+ throw new Error(snapshot.unreadable);
507
+ rev = snapshot.rev;
508
+ const refusal = await args.preflight?.(snapshot);
509
+ if (refusal !== undefined)
510
+ return { landed: false, reason: refusal, committed: "no" };
511
+ }
512
+ catch (err) {
513
+ return { landed: false, reason: `could not read the permission-rule store: ${errText(err)}`, committed: "no" };
514
+ }
515
+ let res;
516
+ try {
517
+ res = await args.writer.apply(args.delta, { expectedRev: rev });
518
+ }
519
+ catch (err) {
520
+ return { landed: false, reason: `the permission-rule store refused the write: ${errText(err)}`, committed: "unknown" };
521
+ }
522
+ if ("refused" in res)
523
+ return { landed: false, reason: SESSION_ENDED_REFUSAL, committed: "no" };
524
+ if (!("conflict" in res))
525
+ return { landed: true, rev: res.rev };
526
+ }
527
+ return { landed: false, reason: `optimistic-concurrency retries exhausted after ${RULE_ADD_MAX_ATTEMPTS} attempts`, committed: "no" };
528
+ }
529
+ export async function addPersistedRule(opts) {
530
+ const behavior = opts.behavior;
531
+ const ruleText = opts.rule;
532
+ const scope = structuredClone(opts.scope);
533
+ const target = opts.principal;
534
+ if (!isRuleBehavior(behavior)) {
535
+ return { status: "failed", error: `addPersistedRule: behavior ${escapeForDisclosure(behavior)} is not one of ${["deny", "ask", "allow"].join("/")} — refusing to name a rule identity nothing can carry` };
536
+ }
537
+ if (behavior === "allow") {
538
+ return {
539
+ status: "failed",
540
+ error: "addPersistedRule: an allow cannot be written through this entry — a standing approval is a person's yes and takes a consent record (prepare → confirm → redeem); this entry writes the tightening direction (deny/ask) only",
541
+ };
542
+ }
543
+ if (!isValidConsentScope(scope)) {
544
+ return { status: "failed", error: `addPersistedRule: ${escapeForDisclosure(JSON.stringify(scope))} is not a valid rule scope — refusing to write a row nothing can read back` };
545
+ }
546
+ const parsed = parseRuleText(ruleText, behavior);
547
+ if ("reject" in parsed)
548
+ return { status: "failed", error: `addPersistedRule: ${parsed.reject.code}: ${parsed.reject.message}` };
549
+ const identity = { behavior, rule: parsed.rule.rule, scope };
550
+ const owner = typeof target === "string" ? { kind: "principal", principal: target } : structuredClone(target);
551
+ const provider = opts.provider;
552
+ if (owner.kind === "local-owner" && provider.forLocalOwner === undefined) {
553
+ return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be added through it" };
554
+ }
555
+ const store = owner.kind === "local-owner" ? provider.forLocalOwner() : provider.forPrincipal(owner.principal);
556
+ const writer = writerOf(store);
557
+ if (writer === undefined) {
558
+ return { status: "failed", error: "the resolved permission-rule store has no write face — rules cannot be added through it" };
559
+ }
560
+ const query = scope.kind === "session" ? { sessionId: scope.sessionId } : undefined;
561
+ let snapshot;
562
+ try {
563
+ snapshot = await store.effective(query);
564
+ }
565
+ catch (err) {
566
+ return { status: "failed", error: `could not read the permission-rule store: ${errText(err)}` };
567
+ }
568
+ if (snapshot.unreadable !== undefined)
569
+ return { status: "failed", error: `could not read the permission-rule store: ${snapshot.unreadable}` };
570
+ if (snapshot.rules.some((r) => sameRuleIdentity(r, identity) && isRuleLive(r)))
571
+ return { status: "no-op", rev: snapshot.rev };
572
+ let dot;
573
+ try {
574
+ dot = await writer.nextDot();
575
+ }
576
+ catch (err) {
577
+ return { status: "failed", error: `could not mint the add's dot: ${errText(err)}` };
578
+ }
579
+ const landing = await landRuleAdd({
580
+ store,
581
+ writer,
582
+ delta: {
583
+ kind: "tighten-add",
584
+ behavior,
585
+ rule: parsed.rule.rule,
586
+ scope,
587
+ tool: parsed.rule.tool,
588
+ match: parsed.rule.match,
589
+ command: parsed.rule.command,
590
+ add: { dot, origin: "user", createdAt: new Date().toISOString() },
591
+ },
592
+ });
593
+ if (landing.landed)
594
+ return { status: "added", rev: landing.rev, dot };
595
+ if (landing.committed === "no")
596
+ return { status: "failed", error: landing.reason };
597
+ try {
598
+ let rev;
599
+ let rows;
600
+ let quarantinedRows = [];
601
+ if (scope.kind === "session") {
602
+ const view = await store.effective(query);
603
+ if (view.unreadable !== undefined)
604
+ throw new Error(view.unreadable);
605
+ ({ rev, rules: rows } = view);
606
+ }
607
+ else {
608
+ const raw = await writer.readRaw();
609
+ ({ rev, rules: rows } = raw);
610
+ quarantinedRows = raw.quarantined ?? [];
611
+ }
612
+ const stood = rows.some((r) => sameRuleIdentity(r, identity) && r.adds.some((a) => sameDot(a.dot, dot))) ||
613
+ quarantinedRows.some((q) => sameDot(q.add.dot, dot));
614
+ return stood
615
+ ? { status: "added", rev, dot }
616
+ : { status: "failed", error: `${landing.reason}; the store retains no trace of this add, which cannot tell a write that never landed from one whose evidence was collected — the outcome is indeterminate, reconcile with list()` };
617
+ }
618
+ catch (err) {
619
+ return { status: "failed", error: `${landing.reason}; the read-back also failed (${errText(err)}) so this outcome is indeterminate — reconcile with list()` };
620
+ }
621
+ }
476
622
  export function errText(err) {
477
623
  return thrownText(err);
478
624
  }
@@ -525,6 +671,10 @@ export class InMemoryDurableRulePartition {
525
671
  assertRedemptionNotQuarantined(this.quarantinedRows, delta);
526
672
  this.rules = foldDelta(this.rules, delta);
527
673
  }
674
+ else if (delta.kind === "tighten-add") {
675
+ assertWriteDeltaScopeDurable(delta);
676
+ this.rules = foldDelta(this.rules, delta);
677
+ }
528
678
  else if (delta.kind === "tighten-delete") {
529
679
  assertWriteDeltaScopeDurable(delta);
530
680
  assertDeleteDeltaCarriesNoAdd(delta);
@@ -382,6 +382,10 @@ export interface Prepared {
382
382
  /** The deployment's own caller policy, re-checked on a durable resume ONLY when the approver
383
383
  * rewrote the pending call's args (see the composition site for why the edit case is special). */
384
384
  basePolicyForResumeEdit?: ToolPolicy;
385
+ /** The leg's shell READ STATION, re-run on a durable RESUME before an approved shell call executes: the parked leg's
386
+ * admission died with that leg, so the resumed bytes are judged (and admitted for their pre-spawn re-establishment)
387
+ * on THIS leg — a deny row found now vetoes, an operator's edit is judged on the `rejudge` seat. */
388
+ shellReadCheck?: import("../../tools/fs/shell-read-check.js").ShellReadCheck;
385
389
  /** The ORG adjudication face, re-resolved on a durable RESUME before an approved
386
390
  * pending call executes. The resume path bypasses the harness gate by design (a human already
387
391
  * adjudicated the checkpointed call), which is exactly where org policy skew is most likely: the
@@ -437,6 +441,14 @@ export interface Prepared {
437
441
  * leg always knows its identity.
438
442
  */
439
443
  hookIdentity: HookInvocationIdentity;
444
+ /**
445
+ * This session's DURABLE box, present iff this leg mounted the session-box drain (a top-level leg
446
+ * over a cross-process-safe mailbox with a grammatical session id). The notification lane binds it
447
+ * to the park seat ({@link import("../task-notification.js").PendingSessionNotifications.bindSessionBox})
448
+ * so a child's uplink frame parked for this session while it is idle lands in the box the next run
449
+ * drains — one mount judgment, the drain and the park read it alike. Absent ⇒ in-process parking.
450
+ */
451
+ sessionBox?: import("../task-notification.js").SessionBoxBinding;
440
452
  /**
441
453
  * The per-invocation TIME BOUND every hook seat of this leg runs under (`Hooks.timeoutMs`, already
442
454
  * validated — a garbage value was refused to the default and disclosed ONCE, here, rather than on
@@ -1252,6 +1264,7 @@ export interface InheritedGate {
1252
1264
  sessionId: string;
1253
1265
  principal?: string;
1254
1266
  rev: number;
1267
+ gen?: number;
1255
1268
  rules: SessionPermissionRules;
1256
1269
  }>;
1257
1270
  /** The chain's effective shell-gate doctrine — the child folds it by max-rank with its own spec. */
@@ -1700,6 +1713,14 @@ export interface RunInternals {
1700
1713
  * every described child a "teammate" (clamp over-wide, addendum over-composed).
1701
1714
  */
1702
1715
  explicitAgentName?: string;
1716
+ /**
1717
+ * The RESOLVED subagent type this child was spawned as (`"general-purpose"` / `"fork"` / a roster
1718
+ * definition's name — the same word the fleet row's `agentType` column carries; never the
1719
+ * description). Read by the hook identity envelope (`identity.agentType`) so a process-level hook
1720
+ * seat can tell WHAT kind of child it is serving without parsing display text. Trusted run-scoped
1721
+ * channel (NOT a {@link TaskSpec} field); absent on a top-level run and on a workflow-lane agent.
1722
+ */
1723
+ spawnAgentType?: string;
1703
1724
  /**
1704
1725
  * The SPAWNING run's taskId, threaded at spawn (from the parent's
1705
1726
  * `ToolExecuteContext.taskId`) so this child's `task_progress` ticks carry `parentTaskId`. Lets a UI build the
@@ -1897,7 +1918,20 @@ export interface RunInternals {
1897
1918
  */
1898
1919
  parentNotify?: (notification: TaskNotificationPayload, opts?: {
1899
1920
  priority?: import("../task-notification.js").SystemInjectionPriority;
1900
- }) => void;
1921
+ }) => unknown;
1922
+ /**
1923
+ * The ONE address `SendMessage("main")` names for this child — the session that hosts this child's
1924
+ * lifecycle — paired with {@link parentNotify} at the same mint, three arms:
1925
+ * · a fresh spawn: the SPAWNER's session, whose live injector is `parentNotify`;
1926
+ * · a revival by that tree's ROOT (or any top-level session): the REVIVER's session, its injector;
1927
+ * · a revival by a SIBLING child, or by a host verb with no session: the row's ROOT session, and
1928
+ * NO `parentNotify` — a sibling is a peer the child addresses by name, not its main, and there is
1929
+ * no live callback into the root from either reviver. Such a child's "main" send reads the
1930
+ * honest not-deliverable text; its terminal still reaches the root through the root-anchor arm.
1931
+ * The uplink receipt names this id so the model knows WHICH conversation "main" is. Absent on a
1932
+ * top-level run. Trusted run-scoped channel (never a {@link TaskSpec} field).
1933
+ */
1934
+ uplinkSessionId?: string;
1901
1935
  /**
1902
1936
  * The PARENT run's subagent-retain ledger, threaded to a CHILD so its
1903
1937
  * SendMessage can continue a RETAINED SIBLING (the sibling's retain entry lives on the parent's
@@ -1917,7 +1951,7 @@ export interface RunInternals {
1917
1951
  */
1918
1952
  onNotifyInjectorReady?: (inject: (notification: TaskNotificationPayload, opts?: {
1919
1953
  priority?: import("../task-notification.js").SystemInjectionPriority;
1920
- }) => Promise<"queued" | "parked" | "dropped_duplicate">) => void;
1954
+ }) => Promise<import("../task-notification.js").NotificationInjectionOutcome>) => void;
1921
1955
  /**
1922
1956
  * Per-task ISOLATION hint, a TRUSTED run-scoped channel filled ONLY by a core caller
1923
1957
  * (the workflow's `ctx.agent` when the SCRIPT passed `{ isolation: "worktree" }` as an OPTION) — NEVER a
@@ -219,6 +219,15 @@ export interface GatePass {
219
219
  * that ended as a refusal). The exit mints the outcome from it beside the ledger's `settled`; a refusal
220
220
  * that reaches the exit without it is an engine defect and the mint throws. */
221
221
  deniedBy: DeniedBy | undefined;
222
+ /** borrowed-mutable — a refusing station's STRUCTURED details (the shell read station's refusal card), written
223
+ * beside `deniedBy` by that station alone; the exit puts it on the gate result for the loop's error result.
224
+ * OPTIONAL (the `probeUnanswered` form): the dispatch that builds the pass has nothing to declare here. */
225
+ denyDetails?: unknown;
226
+ /** borrowed-mutable — the shell read station's containment card behind a standing MANDATED ask (an out-of-root
227
+ * or unestablishable read): when that ask ends as a refusal (a person's no, nobody to ask), the exit puts the
228
+ * card on the gate result, so the refusal's `tool_end` still says WHAT was found (`readonly_out_of_root`) beside
229
+ * WHO refused (`ask_resolution`). Writer: the fold's read station. */
230
+ shellReadDetails?: unknown;
222
231
  /** borrowed-mutable — the FORM of the classifier round behind a deny (`unavailable` / `parse_error`), written
223
232
  * beside `deniedBy` by the gate's own classifier step (its attestation, read once) or by the fold / the
224
233
  * re-check when an inherited station's attested deny arrives through the chain; undefined for a ruling
@@ -251,6 +260,11 @@ export interface GatePass {
251
260
  * twin on the answered side, and narrower: the ask stays ordinary for every ANSWERER, and only the allow
252
261
  * layer's read-only-command arm stands down. Writer: the fold. Reader: that arm, and nothing else. */
253
262
  probeBoundaryDeclined?: true;
263
+ /** borrowed-mutable — the shell read station minted the standing ask's directory-grant offer
264
+ * (`probeReadRootCandidate`). Read by the classify seat's shape probe, which withdraws the offer when its
265
+ * own objection stands beside the station's — the grant answers the station's finding, not the shape's.
266
+ * Writer: the fold's read station. */
267
+ shellReadOffer?: true;
254
268
  /** borrowed-mutable — the write-protection judge covered this call and its table MATCHED: the allow
255
269
  * layer must not clear the surviving ask, a live decider still may. Writer: the fold's fourth tighten,
256
270
  * on BOTH its arms. Reader: the lanes' mandate. */
@@ -135,6 +135,10 @@ export async function exitGate(pass) {
135
135
  block: true,
136
136
  reason: formatHookFeedback(denyReason, input.reminderMark),
137
137
  gate,
138
+ ...(() => {
139
+ const details = pass.denyDetails ?? pass.shellReadDetails;
140
+ return details !== undefined ? { details } : {};
141
+ })(),
138
142
  preToolContext: pass.preToolContext,
139
143
  };
140
144
  }
@@ -1,7 +1,7 @@
1
1
  import type { AgentTool, ExecutionEnv, WorkspaceState } from "../../internal/harness.js";
2
2
  import { type PeerLaneRefs } from "../../agents/peer-session-drain.js";
3
3
  import type { SubagentRetainLedger } from "../../agents/retain-ledger.js";
4
- import type { BashReadonlyRootBoundary } from "../../tools/fs/bash-readonly-classifier.js";
4
+ import type { ShellReadAdmission } from "../../tools/fs/shell-read-check.js";
5
5
  import { type WorktreeSessionRef } from "../../tools/worktree.js";
6
6
  import type { CwdRef } from "../../tools/fs/index.js";
7
7
  import { type ToolResultStore } from "../tool-result-store.js";
@@ -19,7 +19,7 @@ export interface PrepareDelegationSurfaceInput {
19
19
  deps: Pick<RunnerDeps, "workflowRunStore" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "peerAdmission" | "peerDirectory" | "onNotice" | "onBackgroundChildEvent" | "tracer">;
20
20
  /** borrowed-readonly — the trusted spawn-side channel: `parentNotify` (the delegated-child door), the uplink / sibling /
21
21
  * identity seats the SendMessage mount forwards, `onTaskNotification`, `onSubagentSpawn`. Never mutated. */
22
- internals: Pick<RunInternals, "parentNotify" | "parentPeerRef" | "parentRetainLedger" | "explicitAgentName" | "parentTaskId" | "parentSessionId" | "peerSelfRef" | "peerInboundChainRef" | "onTaskNotification" | "onSubagentSpawn"> | undefined;
22
+ internals: Pick<RunInternals, "parentNotify" | "parentPeerRef" | "uplinkSessionId" | "parentRetainLedger" | "explicitAgentName" | "parentTaskId" | "parentSessionId" | "peerSelfRef" | "peerInboundChainRef" | "onTaskNotification" | "onSubagentSpawn"> | undefined;
23
23
  /** borrowed-readonly — the resume leg, or undefined: `seed.activeWorktree` seeds the worktree session (checkpoint first). */
24
24
  resume: Pick<PrepareResume, "seed"> | undefined;
25
25
  /** borrowed-readonly — the acquired session id (the mounts' session axis). */
@@ -37,8 +37,8 @@ export interface PrepareDelegationSurfaceInput {
37
37
  runnerSelf: RunnerSelfSeat | undefined;
38
38
  /** borrowed-readonly — the background/workflow door's first half: the hands mount decided a real shell with write hands. */
39
39
  backgroundTaskToolsActive: boolean;
40
- /** borrowed-readonly — this run's shell read boundary (declared at the hands phase), for `Monitor`: the only P11 face that spawns a shell command. */
41
- shellReadBoundarySeat: (() => BashReadonlyRootBoundary | undefined) | undefined;
40
+ /** borrowed-readonly — this run's shell read station's pre-spawn ADMISSION (declared at the hands phase), for `Monitor`: the only P11 face that spawns a shell command. */
41
+ shellReadAdmission: ShellReadAdmission | undefined;
42
42
  /** borrowed-readonly — the door's second half: the Workflow tool is on the roster. */
43
43
  workflowToolsActive: boolean;
44
44
  /** borrowed-readonly — the peer lane's mount verdict for this leg. */
@@ -56,6 +56,7 @@ export function prepareDelegationSurface(input) {
56
56
  ...(internals?.parentNotify !== undefined
57
57
  ? { uplink: internals.parentNotify, ...(internals.parentPeerRef !== undefined ? { uplinkRecipient: internals.parentPeerRef } : {}) }
58
58
  : {}),
59
+ ...(internals?.uplinkSessionId !== undefined ? { uplinkSessionId: internals.uplinkSessionId } : {}),
59
60
  ...(internals?.explicitAgentName !== undefined ? { senderName: internals.explicitAgentName } : {}),
60
61
  ...(internals?.parentRetainLedger !== undefined ? { siblingRetain: internals.parentRetainLedger } : {}),
61
62
  ...(internals?.parentTaskId !== undefined ? { parentTaskId: internals.parentTaskId } : {}),
@@ -103,7 +104,7 @@ export function prepareDelegationSurface(input) {
103
104
  envHandToolNames.add("Monitor");
104
105
  roster.mount(firstPartyOffload(createMonitorTool(executionEnv, {
105
106
  registry: defaultTaskRegistry,
106
- ...(input.shellReadBoundarySeat !== undefined ? { readBoundary: input.shellReadBoundarySeat } : {}),
107
+ ...(input.shellReadAdmission !== undefined ? { shellReadAdmission: input.shellReadAdmission } : {}),
107
108
  owner: hostTaskId,
108
109
  scope: taskScope,
109
110
  ...(sessionId !== undefined ? { sessionId } : {}),
@@ -31,6 +31,7 @@ import type { ToolPolicy } from "../tool-policy.js";
31
31
  import { type GateOutcome } from "../gate-outcome.js";
32
32
  import type { RunnerDeps, TaskSpec, ToolEffect } from "../types.js";
33
33
  import type { WriteProtectionCheck } from "../write-protect.js";
34
+ import type { ShellReadCheck } from "../../tools/fs/shell-read-check.js";
34
35
  import type { CwdRef } from "../../tools/fs/fs-shared.js";
35
36
  import type { AskLane, IrreversibilityTier, ParkAsk, Prepared, ReversibilityProbes } from "./contracts.js";
36
37
  import type { StopForDenialLimit } from "./denial-limit-arms.js";
@@ -153,6 +154,9 @@ export interface PrepareGateStationsInput {
153
154
  * emptied the table: no judge, and no tighten mounted at all. Carried rather than compiled a second time
154
155
  * here — two compiles are two judges, free to disagree about the seats they were handed. */
155
156
  writeProtectionCheck: WriteProtectionCheck | undefined;
157
+ /** borrowed-readonly — the leg's shell READ STATION (`ToolGateInput.shellReadCheck`), mounted by the hands phase, or
158
+ * undefined with no real shell: no station, nothing judged. */
159
+ shellReadCheck: ShellReadCheck | undefined;
156
160
  /** borrowed-readonly — the per-tool irreversibility tier handed to the gate per call. */
157
161
  irreversibilityTier: IrreversibilityTier;
158
162
  /** borrowed-readonly — the per-tool reversibility probes handed to the gate per call. */