@sema-agent/core 7.24.3 → 7.25.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.
- package/CHANGELOG.md +101 -1
- package/dist/agents/peer-session-drain.d.ts +16 -1
- package/dist/agents/peer-session-drain.js +16 -1
- package/dist/agents/send-message-tool.d.ts +10 -2
- package/dist/agents/send-message-tool.js +18 -7
- package/dist/agents/subagent.js +13 -10
- package/dist/core/background-agent-store.d.ts +3 -3
- package/dist/core/checkpoint-store.d.ts +1 -0
- package/dist/core/delegation-frames.d.ts +5 -0
- package/dist/core/gate-fold.js +85 -0
- package/dist/core/gate-lanes.js +23 -1
- package/dist/core/gate-outcome.d.ts +8 -2
- package/dist/core/gate-outcome.js +3 -1
- package/dist/core/hooks.d.ts +29 -0
- package/dist/core/hooks.js +2 -0
- package/dist/core/mailbox-store.d.ts +5 -0
- package/dist/core/mailbox-store.js +1 -1
- package/dist/core/parent-spec-seat.d.ts +7 -1
- package/dist/core/parent-spec-seat.js +2 -0
- package/dist/core/permission-rule-consent.js +29 -47
- package/dist/core/permission-rule-provider.js +4 -3
- package/dist/core/permission-rule-store.d.ts +143 -7
- package/dist/core/permission-rule-store.js +165 -15
- package/dist/core/permission-rules.js +14 -10
- package/dist/core/rule-denial-sentence.d.ts +75 -0
- package/dist/core/rule-denial-sentence.js +16 -0
- package/dist/core/runner/contracts.d.ts +36 -2
- package/dist/core/runner/gate-exit.d.ts +14 -0
- package/dist/core/runner/gate-exit.js +4 -0
- package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
- package/dist/core/runner/prepare-delegation-surface.js +2 -1
- package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
- package/dist/core/runner/prepare-gate-stations.js +7 -4
- package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
- package/dist/core/runner/prepare-hands-readface.js +25 -10
- package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
- package/dist/core/runner/prepare-inherited-gate.js +11 -6
- package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
- package/dist/core/runner/prepare-policy-chain.js +42 -22
- package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
- package/dist/core/runner/prepare-suspend-saga.js +4 -3
- package/dist/core/runner/prepare-task.js +10 -9
- package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
- package/dist/core/runner/prepare-wiring-manifest.js +8 -4
- package/dist/core/runner/resume-apply.js +2 -0
- package/dist/core/runner/run-harness-handlers.js +1 -0
- package/dist/core/runner/run-identity-wiring.d.ts +2 -2
- package/dist/core/runner/run-leg.js +28 -1
- package/dist/core/runner/run-notification-lane.d.ts +2 -2
- package/dist/core/runner/run-notification-lane.js +2 -1
- package/dist/core/runner/run-settle-and-teardown.js +1 -0
- package/dist/core/runner/session-rule-policy.d.ts +73 -2
- package/dist/core/runner/session-rule-policy.js +121 -21
- package/dist/core/session-policy-store.d.ts +27 -3
- package/dist/core/session-policy-store.js +12 -4
- package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
- package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
- package/dist/core/store-contracts/peer-directory-contract.js +82 -0
- package/dist/core/task-event.d.ts +6 -0
- package/dist/core/task-notification.d.ts +79 -4
- package/dist/core/task-notification.js +90 -1
- package/dist/core/task-registry-agent.js +1 -2
- package/dist/core/task-registry-monitor.js +2 -1
- package/dist/core/task-registry-shared.d.ts +53 -5
- package/dist/core/task-registry-shared.js +13 -2
- package/dist/core/task-registry.js +8 -5
- package/dist/core/task-spec.d.ts +12 -5
- package/dist/core/tool-catalog-entries.js +1 -0
- package/dist/core/tool-spec.d.ts +1 -1
- package/dist/core/wiring-manifest.d.ts +2 -2
- package/dist/engine/loop/agent-loop.js +1 -1
- package/dist/engine/loop/types.d.ts +4 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -3
- package/dist/orchestration/workflow.js +12 -3
- package/dist/prompts/coordinator.d.ts +2 -1
- package/dist/stores/file/adoption/adopt.js +23 -1
- package/dist/stores/file/permission-rule-store.js +3 -1
- package/dist/stores/file/session-policy-store.d.ts +31 -0
- package/dist/stores/file/session-policy-store.js +87 -7
- package/dist/tools/fs/bash-program-position.d.ts +4 -26
- package/dist/tools/fs/bash-program-position.js +0 -11
- package/dist/tools/fs/bash-readonly-classifier.d.ts +20 -309
- package/dist/tools/fs/bash-readonly-classifier.js +28 -766
- package/dist/tools/fs/fs-bash.d.ts +43 -207
- package/dist/tools/fs/fs-bash.js +20 -341
- package/dist/tools/fs/index.d.ts +12 -13
- package/dist/tools/fs/index.js +3 -4
- package/dist/tools/fs/read-deny.d.ts +25 -0
- package/dist/tools/fs/read-deny.js +19 -0
- package/dist/tools/fs/safety.js +2 -6
- package/dist/tools/fs/shell-read-check.d.ts +99 -0
- package/dist/tools/fs/shell-read-check.js +229 -0
- package/dist/tools/fs/shell-read-establish.d.ts +70 -115
- package/dist/tools/fs/shell-read-establish.js +216 -102
- package/dist/tools/fs/shell-read-judge.d.ts +231 -0
- package/dist/tools/fs/shell-read-judge.js +170 -0
- package/dist/tools/fs/shell-read-plan.d.ts +3 -0
- package/dist/tools/fs/shell-read-plan.js +82 -19
- package/dist/tools/monitor.d.ts +5 -5
- package/dist/tools/monitor.js +3 -5
- package/dist/tools/task-list.d.ts +4 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +19 -3
- package/wire-changes/core@7.24.4.json +54 -0
- package/wire-changes/core@7.25.0.json +671 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,87 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 7.25.0 — 2026-09-20
|
|
4
|
+
|
|
5
|
+
### One station judges every shell read, before approval (#945 S1; BREAKING ×3; one narrowing, three widenings; #976 closed structurally)
|
|
6
|
+
- **What lands.** The gate's shell READ STATION (`src/tools/fs/shell-read-check.ts`, mounted per leg by the hands phase): ONE parse of a shell command into its read candidates (`planShellReads`, S0), each ESTABLISHED against the execution environment (`shell-read-establish.ts`: canonical target; a pattern expanded in both the kernel and the folded spelling of a `..` operand; a traversal root enumerated within `MAX_GLOB_DIRS`/`MAX_GLOB_MATCHES`; lexical under an isolated sandbox) and JUDGED once (`shell-read-judge.ts`: `judgeRead` — the deny judge over every view first, containment over the canonical target; `SHELL_READ_DECISION`, the total finding × seat table that is the only source of refuse / mandated ask / ordinary ask / vouch; `readRootGrantCandidateOf`; `rejudge`). The station is a POLICY-class member of the gate fold (`gate-fold.ts` `applyShellReadVerdict`) for every shell face — `Bash`, `Monitor`, the read-only `Bash` — under every doctrine (`shellGate` "off" installs no tier; "classify" adds only the SHAPE classifier), and re-judges an approved rewrite's reads after a person's yes (`gate-lanes.ts`, `seat: "rejudge"`); the pre-spawn re-establishment refuses a call whose targets moved while the card was open. One deny sentence for every read face (`readDenySentence`, `read-deny.ts`), with each variable part elided in its middle at `READ_DENY_SENTENCE_PART_MAX`.
|
|
7
|
+
- **BREAKING (1) — `DeniedBy` + `"read_boundary"`** (`DENIED_BY_VALUES`, `src/core/gate-outcome.ts`; `DENIED_BY_MAY_VETO.read_boundary = true`, `DENIED_BY_MAY_CARRY_CAUSE.read_boundary = false`): the station refused (a deny-listed read before any approval — D path; an approved rewrite re-judged onto a deny row — V path beside `settlement.kind: "human_allowed"`). Who is hurt: a server dashboard/alert that buckets `deniedBy` on the closed set (one bucket to add); cli if it renders `deniedBy`; client-core/sdk type rows. Headless, a deny-listed read is now `read_boundary` where it was `ask_resolution`.
|
|
8
|
+
- **BREAKING (2) — `bashReadBoundaryProbe` is removed from the package export surface** (`handoff:diff v7.24.2 HEAD`: −1, +0). The read-boundary probe seat is retired: the engine's shell seat carries the SHAPE classifier alone (`bashReversibilityProbe`, kept). Who is hurt: a library consumer wiring the probe directly (grepped across server / cli / client-core / sdk: none); cli's `shellReadBoundaryContract.ts` cites its JSDoc — the contract sentence `shell.read_boundary.friction_floor` now lives on `judgeRead` (`src/tools/fs/shell-read-judge.ts`).
|
|
9
|
+
- **BREAKING (3) — `CompoundReadonlyVerdict` (exported type) narrows to `{ reason?: string }`**: `classifyCompoundReadonly` answers shape only; the 16 read-boundary members (`outOfRootRead`, `outOfRootPaths`, `readDenied`, `readDeniedPath`, `readDeniedPattern`, `checkedPaths`, `undecidedPaths`, `checkedSpellings`, `undecidedSpellings`, `outOfRootSpellings`, `unresolvedOperands`, `refusedWhole`, `recursiveReadPaths`, `outOfRootGrantCandidate`, `outOfRootGrantCoversExactPath`, `segmentNameRefusal`) are gone with the walk that minted them. Who is hurt: a consumer reading those members (grepped: none). `ReversibilityVerdict` keeps every member; the engine no longer mints `mandated` / `boundaryDeclined` / `readRootCandidate` through it (same wire keys and values, written by the station on the decision).
|
|
10
|
+
- **NARROWING (F-row class A).** A deny-listed shell read is REFUSED on every face and both moments — no card, an approver's yes cannot release it (C-R71): the full shell under `classify` or `off` (was: a mandated card, YES read the bytes), `Monitor`, headless (was: an unanswerable card = `ask_resolution` auto-deny), and a deny row inside `$(…)`, `` ` ` ``, `<(…)`, `sh -c`, behind a launcher, or under a `<`/`>` redirection (KL-939c/d, was: a card or a zero-ask read). The refusal carries `read_path_denied` (`target`, `pattern`) on `tool_end.structured` / `errorCode` and the five-part sentence in the result text. 47 further cells keep their disposition and move the code from the tool's own refusal to the gate's result (`deniedBy: "read_boundary"`, or the containment card beside `ask_resolution` for a card nobody could answer).
|
|
11
|
+
- **WIDENING (F-row classes C / 4c / 4d, each with its headless reading; ALIGNED with CC `outsideReadsBlocked` = ask).** (C) On the full shell with an approver, a symlink / bracket-named link / pattern expansion out of the roots (`cat outlink`, `cat vendor/*`, `cat '[c]d.txt'`) is ONE mandated card naming the canonical target with `readRootCandidate` — YES runs it (was: zero asks, then the execute leg refused); headless and the read-only shell are unchanged (refused). (4c) An expansion the station cannot finish (`cat sub/[z-a]`, past the bounds, a declared-authoritative resolver that answers nothing) is a mandated card naming the cause (was: execute-time refusal); headless / read-only unchanged. (4d) An aliased spelling of an IN-root path (`/tmp/<x>` for `/private/tmp/<x>`) runs with zero cards (was: a mandated card) — and headless it RUNS where it was auto-denied (the one headless flip, listed on purpose). Also: the read-only shell's contents traversal (`grep -r`, `diff -r`, `rg`) is ENUMERATED — a deny row under the tree refuses naming it, no row runs, past the bound refuses (KL-937b reversed, KL-934b retired); `Monitor`'s in-root pattern runs with zero cards; a `cd <dir>` target is its own grant candidate.
|
|
12
|
+
- **One rule replaces two special cases (KL-945a/b).** Containment judges what a READ program opens; the deny judge meets every path-shaped word. A leading `NAME=value` word's value (`PYTHONSTARTUP=/etc/x python3`, `FOO=/etc/passwd cmd`) and a program path (`/bin/cat f`, `sudo /bin/cat f`, `~/.nvm/…/node app.js`) are no containment candidates (zero cards where the base tree mandated) and remain deny candidates (`KEY=~/.ssh/id_rsa cmd` is refused); a program word the shell rewrites (`$CMD f`) stays unestablishable.
|
|
13
|
+
- **#976 closed structurally (F-row class D).** A `<` redirection source is a read candidate established to its canonical target: `cat < outlink` / `wc -c < outlink` / `cat < vendor/outside.txt` are one mandated card on the full shell (was: a zero-ask true read through the link), refused headless and on the read-only shell; `cat < .env` is refused.
|
|
14
|
+
- **Headless park.** The station is the third source of a mandated ask, so it arms the durable park vocabulary: an unattended out-of-root read under the `off` doctrine with a checkpoint store PARKS (`irreversible_ask`, `axes: { shell }`, `shellGateDoctrine: "off"`) instead of falling to a fail-closed deny.
|
|
15
|
+
- **The two moments after the verdict.** A durable RESUME runs the station on the resumed leg before the approved shell call executes (the parked leg's admission died with it): a deny row found now vetoes the person's yes (`deniedBy: "read_boundary"` beside `settlement.kind: "human_allowed"`), an operator's edit (`updatedInput`) is judged on the `rejudge` seat (an edit onto a deny row or outside the roots is refused — KL-945c), the unedited call runs. The pre-spawn admission is bound to the judged bytes on the judged tool (a call whose executing command differs from the judged one is refused before the spawn), and the re-judge of an approved edit replaces the admission it re-judged. In the adversarial round: a traversal root that is a pattern (`grep -r x dir*`) is expanded and every match enumerated; a traversal root OUTSIDE the roots is enumerated too, so a deny row under it refuses instead of riding a card an approval could clear; a substitution nested in a segment that `cd`-ed (`cd sub && cat <<EOF\n$(cat secret)\nEOF`) is judged where the shell stands; sed's execution modes (`e`, `s///e`) are a shell re-entry; a text that reassigns `HOME` is not folded on the declared home; a no-path program's words and a launcher's declared option values (`env -u NAME`) are no candidates.
|
|
16
|
+
- **Deleted (§5 rows 2–12 of the design):** the three shell tools' execute-time read re-checks, the two probe seats' read halves, the `bash_readonly` self-judging leg, `refusedCommandMayRead` and the containment gate, `readBoundaryMandate`, `BoundaryQuestions`, the deny-account mints, the seat decline tables (`*_SEAT_READS_DECLINE`), the grant replay — `fs-bash.ts` 3 114 → 2 091 lines (banked). KNOWN-LIMITS: KL-934b / 937a retired, KL-937b reversed, KL-938b / 939c / 939d closed, KL-934a kept (lexical under an isolated sandbox), KL-945a/b added.
|
|
17
|
+
- Reach: @server (deniedBy bucket, keyset projection, autonomy-tier wording) / @cli (one test re-pin, one contract citation, an optional deniedBy row) / @client-core (type row); headless: deny hit = `read_boundary`, alias spelling of an in-root path runs, everything else unchanged. Wire table: `docs/wire-consumption/unreleased-945.md`.
|
|
18
|
+
|
|
19
|
+
### A removal's read-back tells three things apart, not two (#980 follow-on; P2; BREAKING — `RemoveResult.stillLive` is a word, not a boolean; @server @cli)
|
|
20
|
+
- **Before.** `removePersistedRule` answered `stillLive: false` whenever its add-wins read-back failed — `catch { stillLive = false }`. `false` on that arm is a claim about a rule nobody managed to look at, and it renders "we could not see it" as "it is not there": the one sentence this domain refuses everywhere else (`createLiveSessionRules` says it in as many words — "we cannot see the rules" is never "there are none"). The add entry had already been given `committed: "no" | "unknown"` for exactly this asymmetry in the version that introduced it, and named the removal's twin as a same-class observation "for the owner to rule on". This is the ruling.
|
|
21
|
+
- **After.** `stillLive` is `RemovalLiveness` — `"yes" | "no" | "unknown"`, a registered closed set (`REMOVAL_LIVENESS_WORDS`). `yes` and `no` are things the store said; `unknown` is the honest word for a read-back that threw or came back unreadable. The tombstone's landing is unaffected and still reported as `removed` with its `rev`: what the third word withholds is the add-wins observation, not the removal.
|
|
22
|
+
- **The `failed` arm gains the SAME two strengths the add's has.** Decided by WHERE the failure happened — a flag the catch arm sets, never the error's message (`new Error()` carries an empty one, and reading the strength off `lastError !== ""` called a thrown apply an answered compare-and-set, the very inference-from-prose this entry exists to stop): when every attempt lost its compare-and-set the store ANSWERED each time and the absent tombstone is the confirmation (plain sentence, unchanged). When the apply THREW, a missing tombstone cannot tell a write that never landed from one whose evidence a paired collection erased — `collectBelowFrontier` recycles a tombstone together with the adds it covered — so that arm now reads `the outcome is indeterminate, reconcile with list()`. A blind retry there re-observes the CURRENT dots and can tombstone an approval granted since.
|
|
23
|
+
- **BREAKING, three sentences.** (1) **What changed:** `RemoveResult`'s `removed` arm carries a three-word closed set where it carried a boolean, and its `failed` arm's text splits into two strengths. (2) **Who is affected:** every reader of `stillLive`. In tree, none — the field has no branch consumer outside its own mint site and pins. Out of tree: **@server** relays `RemoveResult` verbatim and puts `stillLive` STRAIGHT ON THE WIRE (`http/routes/rules.ts:600`), so a revoke's 200 body changes shape; **@cli** branches on that wire value's TRUTHINESS, and all three words are truthy strings, so an unmigrated cli renders "still live (approved again meanwhile)" for a rule that is plainly gone — silently, and in the direction that never reports a removal. **@client-core** has zero references. (3) **How to migrate:** read the word — `=== "no"` is "gone", `=== "yes"` is add-wins, `unknown` is "not observed, reconcile with `list()`". A two-way consumer folds `yes` and `unknown` together, because the default reading of an unobserved rule is **still there**; folding `unknown` into `no` re-introduces exactly the over-claim this change removes.
|
|
24
|
+
- **Reach.** No new engine behaviour, no wire KEY, no notice code, no export added or removed — one exported type's field changes type. `@server` additionally owns a decision core does not make for it: `routes/rules.ts:619` mints its own `stillLive` boolean on the `no-op` arm so both 200 bodies share a key set, and whether that arm carries the same three words is server's call (its re-list failure is the same "nobody looked" shape). `docs/wire-consumption/unreleased-980.md` carries the table. `test/backlog980-remove-readback.test.ts` pins the eight readings — the two observed words, both unobservable arms, the raw-face reconcile, the collected-tombstone hedge, the exhausted-CAS negative control, and a consumer double-control that a real delete takes the gone-branch while a read failure does not — with a six-row mutation table (the sixth restores the message-derived strength and reds exactly the empty-message arms).
|
|
25
|
+
|
|
26
|
+
### A session's rev is a high-water mark the erase cannot roll back (#982; P2; behavior-face narrowing on both bundled policy stores + one additive error code; no wire key, no export; @server @test)
|
|
27
|
+
- **Before.** `rev` counted within ONE row's lifetime: both bundled `SessionPolicyStore` backends computed it as `(prior?.rev ?? 0) + 1`, and `deleteBySession` removed the only record of the number. So the first write after an erase came back as rev 1, and — since tighten-only has no prior row to judge a recreation against — an ordinary non-operator could publish a LOOSER rule set wearing a rev a reader had already passed. A row read as ABSENT for any other reason (a torn/corrupt file, which this store deliberately fails open on) had the same effect.
|
|
28
|
+
- **After.** The number outlives the row. Every stamp is `max(current row's rev, the key's mark) + 1`, per `(sessionId, principal)` key, so the first write after an erase is strictly above every rev that key ever carried and no two rule sets of one key ever wear the same rev. `getRules` is unchanged — an erased key still reads `null`, because an absent row is absence and not a rev-0 row — and so is CAS: `expectedRev` keeps comparing against the ROW, so `0` still matches after an erase (it says "I know there is no row") while the pre-erase rev is a `conflict`.
|
|
29
|
+
- **Where the mark lives.** `InMemorySessionPolicyStore` keeps a second map beside the rows. `FileSessionPolicyStore` keeps one small file per key, `<stem>.hwm.json`, beside the row that shares its stem — written BEFORE the row (a crash between them leaves the mark one ahead, which costs a skipped number; the opposite order leaves a published rev the mark never learned, and that number can then be reissued), skipped by both directory sweeps (a mark is not a row: it never lists, it is never erased, and the unparseable-file sweep never judges it), and folded from a row's own rev at erase time so a deployment whose rows predate the mark does not restart at 1.
|
|
30
|
+
- **One additive error code.** `SessionPolicyError`'s code set gains `corrupt` (closed set +1; `conflict` / `loosen_forbidden` unchanged): a mark whose bytes cannot be read refuses the write and names the file, because reading it as absent would BE the rollback. That is the one fail-CLOSED face of a store that otherwise fails open — a corrupt ROW still degrades to absence and `getRules` still never throws on one.
|
|
31
|
+
- **Every sweep of that directory, not just the store's two.** Ownership adoption rebinds policy files by `__principal`, which a mark carries too — so the rebind leg now dispatches on the suffix it takes from the store: a mark travels with its key, two marks MERGE by maximum (a mark is a number; two ROWS still refuse to merge through a rewrite), and a mark whose row is already erased travels as well, because the adoption transfers the identity that mark belongs to.
|
|
32
|
+
- **Reach.** `@server` owns the SQL twin (S-529) and must carry the same rule — a backend that answers "no row" as rev 0 has to keep the mark past the row. `@test` black-box: the reading is "erase a session's policy, write it again, and the rev is above the old one, not 1". An operator tool that enumerates a data dir's `session-policy/*.json` must now skip `*.hwm.json`. RESIDUALS recorded as `KL-982a` (the post-erase first write is still not bound by the erased row's denies — the read side's vanished-row rule is what holds the floor), `KL-982b` (the mark is never collected) and `KL-982c` (an unreadable mark blocks that key's writes until a person removes the file). `test/backlog982-session-policy-rev-high-water.test.ts` pins eleven readings across both twins; `#978`'s G11/G14 keep their subjects with the old arithmetic taken out of their premises.
|
|
33
|
+
|
|
34
|
+
### An ancestor rule enforced mid-run reaches the GRANDCHILDREN (#981, narrowed; P2; behavior-face tightening; no wire key, no export; @server @cli @test)
|
|
35
|
+
- **Before.** #978 gave every rule source a per-gate re-read and published THIS leg's enforced row for its own children (`onEnforced` → `ownSessionRulesRef`). The ANCESTOR layers got the live reader but no publication hook, so the chain a middle leg handed its OWN children still carried the rev that leg prepared with. The middle leg itself is bound correctly — it re-reads at every gate — which is exactly why the gap hid: a grandchild that CAN read the store reaches the same row anyway. It bites in the one case the seed exists for: a grandchild whose store read fails (or that runs where no store is wired) falls back to the floor, and the floor was the older, LOOSER snapshot — so "a failed read never loosens" became false across one generation.
|
|
36
|
+
- **After.** The ancestor layer takes the same `onEnforced` the own-rules layer does, upgrading that ancestor's entry in the inherited chain to the row the layer now enforces. A grandchild spawned after the tightening seeds from the tightening. Monotonic by construction: the reader fires only when its floor ADVANCES on a strictly greater rev, so an entry can never be walked backwards, and the array is never re-ordered, appended to or replaced — the entry is a cache of the same fact getting fresher.
|
|
37
|
+
- **The upgrade is PER-LEG, and that is the load-bearing half.** It is recorded in a seat beside `ownSessionRulesRef` and overlaid on the inherited entries at spawn time; the entries themselves are never written. Writing them in place — the obvious reading of "publish it back" — is wrong in the LOOSENING direction, and was measured as such: a whole-row replacement includes relaxation, so one leg reading one store at rev 6 erased a shared snapshot's own rev-5 deny for a second leg whose store was a lagging replica (it reds the existing M17 transcript pin, which shares its snapshot object across two cases). Those entries belong to whoever built the chain — a host's re-supplied `internals.inheritedGate`, a rehydrated checkpoint, an ancestor leg's published row that every sibling it spawns is holding — so `internals.inheritedGate.ancestorRules` is unchanged by a run, exactly as before.
|
|
38
|
+
- **Two faces, ONE projection — the checkpoint half moves with the spawn half.** What a later reader inherits is what this leg enforces, and a resume is a later reader. The checkpoint writer cloned the PREPARE-TIME list, so a mid-run tightening reached a later child but not a later resume — and a cross-process resume whose ancestor store is unreadable fell back to the looser snapshot. Both faces now call the same `ancestorRulesNow()`; keeping them separate was two implementations of one security rule.
|
|
39
|
+
- **…and the list those positions index is OWNED.** The chain resolver could hand back the caller's own array verbatim (`internals.inheritedGate.ancestorRules`, or a checkpoint seed's). A position is only a fact if the list is ours, so it is copied once per leg — a host that reorders or splices its array between prepare and spawn can no longer have one layer's row land on another layer's entry. The ENTRIES stay shared, which is correct: nothing writes to them.
|
|
40
|
+
- **The seat is keyed by POSITION, not by identity.** The chain may legitimately carry two entries of the same `(sessionId, principal)` with DIFFERENT floors (a host rebuild; a seed and a live channel that both name it), each compiled into its own layer with its own floor. An identity key lets the layer that advanced overwrite the entry of the layer that did not — publishing the weaker floor over the stronger one, measured as a child inheriting no deny at all. A layer upgrades its OWN entry, and position is what "its own" means.
|
|
41
|
+
- **The other half of #981 is untouched and stays on the ledger.** A session with no rules row at prepare still sends NO `ancestorRules` at all, so a child spawned before a session's first rule inherits no entry to upgrade. That is not a missing hook: `ancestorRules` doubles as the "this leg is delegated" signal, so an empty entry cannot be seeded without re-meaning that signal for every delegated child of every store-bearing deployment. Splitting delegation identity from rule content is a separate change.
|
|
42
|
+
- **Reach.** No wire key, code, closed-set member or export moves; one hook argument. `@test` — a grandchild e2e (parent writes a deny between two spawns; the later grandchild's own read fails) is the black-box shape. `@server` / `@cli` render the refusal they already render. `test/backlog981-ancestor-enforced-publish.test.ts` pins three real generations on a real Runner with one store — the earlier grandchild runs the tool (control), the middle leg's own next call is refused (control for the re-read mechanism 7.24.4 already has), and the later grandchild is refused with the store unreachable, `probe.ran` being **2 on 7.24.4** and 1 with the hook — plus the isolation reading (two legs sharing one entry, the second against a different store, and the snapshot's own deny still standing), the duplicate-identity reading, the checkpoint reading, and a host that reorders its own chain array mid-run.
|
|
43
|
+
|
|
44
|
+
### A background poll body says how it may be read again (#977; P2; ADDITIVE — one new details key on three faces; @server @client-core @test)
|
|
45
|
+
- **Before.** Three poll faces render a background task's output and two of them mean OPPOSITE things: the monitor spool and the bash spool re-render the whole output from the registry's own spool on every poll (replace what you hold), while the bash blocked wait serves only what is new since the previous read, because it consumed the env cursor (append). Which one a consumer had was legible only from the stdout heading's prose — `stdout (full, re-readable)` versus `new stdout`. The one machine key nearby reads the two faces oppositely: `bytesDroppedBeforeCursor` rides the spool faces ONLY when bytes were actually dropped, and the blocked-wait face unconditionally (value possibly `0`). So a consumer discriminating on that key's PRESENCE — which is what server's `cursorSemantics` did — reads a spool that happened to drop bytes as an increment, and client-core's `mergeTaskOutput` appends a body that was a full re-render: the same section printed twice.
|
|
46
|
+
- **After.** `details.details.replay` — `"full" | "cursor"`, the registered closed set `TASK_OUTPUT_REPLAY_KINDS`. Present IFF the poll carries an output body, which is exactly those three faces; a `not_ready` arm, an invalid-filter refusal and a poll error carry no body and no word, so **absence means "there is no output body here", never `full`**.
|
|
47
|
+
- **One mint, and the prose comes out of it too.** The word is named once per face (`SPOOL_REPLAY` / `INCREMENT_REPLAY` in `task-registry-shared.ts`) and the stdout/stderr HEADINGS each face renders are rows of the same table (`REPLAY_FACE`) — so the banner a person reads and the key a program reads are one decision, not two that drift. The table's `stdoutWhenGapped: undefined` on the cursor face is a row, not an omission: an increment window claims nothing about completeness to downgrade, while a spool advertising itself as "full" with a disclosed gap would be saying something false. Every rendered body is byte-identical to 7.24.4.
|
|
48
|
+
- **`bytesDroppedBeforeCursor` is deliberately NOT unified.** Same name, same type, same two in-presence rules, same arithmetic. Each rule is right for its own face ("say it when there is something to say" / "always a number on this leg"); reading them as a lane discriminator was the defect, and changing a disclosure to fix a discrimination would trade one wrong reading for another.
|
|
49
|
+
- **The in-presence rule is "this answer carries a STREAM BODY", not "this poll succeeded" and not "this answer has text".** The `workflow` and `background_agent` poll faces carry a FINISHED RESULT (`result` / `finalOutput`), not a stream: polling again answers the same text, so there is no replay question to put on them and they carry no `replay`. A consumer must not append those, and must not read their missing `replay` as a pre-7.25.0 producer. Within the stream family the rule is exact: The blocked wait's poll-ERROR return salvages the increments it had already consumed from the env cursor ("output accumulated before the error") — those bytes are gone from the source, so that answer carries task output and now carries `replay: "cursor"` in the same nested place every other face puts it. One predicate decides both the salvage and the word, so they cannot drift.
|
|
50
|
+
- **Reach.** Additive — no existing key changes name, type, value or in-presence rule, no export moves, no closed-set member is re-meant. `@server` replaces the `bytesDroppedBeforeCursor`-presence test in `runs.js` with `replay === "cursor"` and retires the presence arm. `@client-core` appends iff `cursor`, replaces iff `full`, and keeps its current heuristic only where the key is absent AND a body is present (a pre-7.25.0 producer). `@test` — T-37's judging eye is `replay: "full"` BESIDE a present `bytesDroppedBeforeCursor`, the reachable pair a presence test calls a cursor. `docs/wire-consumption/unreleased-977.md` and `docs/INTEGRATION-CORE.md` §1.3a carry the obligation. `test/backlog977-replay-face.test.ts` pins seven readings across all three real registry lanes — including the misread reproduced end to end, and both halves of the error arm — with a seven-row mutation table.
|
|
51
|
+
|
|
52
|
+
## 7.24.4 — 2026-09-20
|
|
53
|
+
|
|
54
|
+
### Every rule source is re-read at every gate (#978 ①; P2; behavior-face widening of when a session rule binds; no wire key; @server @cli @test)
|
|
55
|
+
- **Before.** `sessionPolicyStore.getRules` was read ONCE, at prepare, and the compiled rules were frozen for the leg; only the ANCESTOR snapshots were re-read live, and only at prepare too. The permission-rule store, meanwhile, is read at every adjudicated call. So a deployment that wrote a `deny` to a session's policy row while the run was going — the ordinary shape of an operator narrowing a task already in flight — watched the run's next calls execute anyway; the deny bound the NEXT run. Measured downstream: the row was written at the first `tool_end` and 3/3 later calls of that tool still ran. Worse, a session with NO row at prepare composed no layer at all, so a session's FIRST rule could never bind mid-run whatever the read tempo.
|
|
56
|
+
- **After — one law, both stores.** A session-rule layer takes its rules from a `SessionRuleSource` at EVERY gate. The prepare read is a SEED, not the reading: it is what the prepare-time policy-name audit sees and the floor a live read may never loosen. The layer is composed whenever the store is wired for the session, including when the row is empty (an empty seed constrains nothing, and a layer that was never composed cannot re-read anything). The ancestor lane uses the same reader rather than its own hand-written live-preferring read.
|
|
57
|
+
- **Direction, unchanged.** Whole-row replacement at `live.rev >= floor.rev` — an operator's AUTHORIZED change propagates in both directions (tighten and relax). What the floor forbids is an UNAUTHORIZED loosening: a lagging replica, a deleted row and an unreachable store each enforce the floor instead. The floor RATCHETS to the highest-rev row the layer has served, so a row tightened mid-run and then unreadable cannot fall back past the tightening.
|
|
58
|
+
- **Cost and loudness.** One store read per adjudicated call per layer, shared inside one call by the same bounded per-call handoff the permission-rule lanes use (first reader hands, second claims). That handoff has a real firing path here: `policyLayers` re-appends the narrowing layers AFTER a rewrite-capable caller layer, so one adjudication asks the layer twice under one id and both passes must judge one set of rules. A source that cannot be read discloses once per distinct reason through `onError` (`phase: "config"`, `code: "config.session_rules_unreadable"`) — the advisory channel the policy-name audit already uses, not a new notice code.
|
|
59
|
+
- **The read is BOUNDED.** This is the only I/O the narrowing fold performs, and the resumed-execution path awaits that fold with a consumed approval already claimed — a store that never settled would strand the call rather than refuse it. The read now rides `withGateTimeout` on the gate's own signal and the tree's default deadline; an elapsed or aborted wait takes the same fail-closed arm as a throwing store (enforce the floor, disclose once).
|
|
60
|
+
- **A vanished row ends its rev line.** `rev` counts within ONE row's lifetime — both bundled backends restart it at 1 after a delete, and the tighten gate has no prior row to judge the first write against, so a recreated row's rev can be climbed past an enforced floor by ordinary non-operator writes (measured: enforce rev 2, delete, three `{}` writes, rev 3 replaces the restriction). Once a row this layer served is observed GONE, nothing that appears afterwards is comparable to it and the floor is final for the leg. COST: a legitimate delete-then-recreate is not picked up until the next run, and a backend that answers "no row" transiently ends the line early — both conservative. A session that simply never had a row is unaffected and silent (absence is not a deletion).
|
|
61
|
+
- **Five bounds an adversarial round added.** (a) A live row replaces the floor only on a STRICTLY GREATER `rev`: both bundled backends compute `rev` as `(prior?.rev ?? 0) + 1`, so a DELETED-and-recreated row restarts at 1, and at `>=` a recreated row that merely tied the enforced one would replace it — an unauthorized loosening wearing an update's shape. A tie now enforces the floor (a no-op for the honest case, where equal rev is the same row). This raises the cost of the recycle rather than closing it — the rev can still be climbed past the floor by repeated writes, because the tighten gate has no prior row to judge against after a delete; closing it belongs to the STORE's rev lifecycle (a counter that survives deletion, or a generation identity) and is a cross-backend contract change. (b) A handed read is CLAIMED only while its `rev` still stands at the enforced floor: `toolCallId` is not always unique (the turn-boundary read-visibility query reuses one synthetic id for every path and every turn), and a claim from a later adjudication would otherwise walk back past a tightening the layer already applied. (c) The enforced row is published for the child chain from the same place it is enforced, each time the floor advances — so a rule written mid-run reaches a child spawned after it, and a child whose own store read fails falls back to its parent's CURRENT floor rather than an older one. The empty seed is never published (it is not a row a child may inherit).
|
|
62
|
+
- **Reach.** `createSessionRulePolicy`'s first parameter widens to `SessionPermissionRules | SessionRuleSource` (additive; a plain rules object is still the constant source and every existing caller is unchanged). No wire key, code or closed-set member moves. RESIDUAL, recorded: a child spawned BEFORE the session's first row exists inherits no ancestor entry — seeding an empty one would put an `ancestorRules` row on every delegated child of every store-bearing deployment, which other readers take to mean "this leg is delegated". `test/backlog978-session-rules-every-gate.test.ts` pins the mid-run first deny, the mid-run tighten, the authorized relaxation, the three fail-closed arms (store down, lagging replica, deleted row), the ancestor lane, the read count, the handoff, the rev-recycle / claim-revalidation / vanished-row / bounded-read arms, the child-chain publication, and the two no-rule controls; 8 of the 10 Runner-level ones are red on 7.24.2.
|
|
63
|
+
|
|
64
|
+
### One sentence for "a rule refused this call" (#978 ②; P2; refusal TEXT change on two lanes; no wire key; @cli @server @test)
|
|
65
|
+
- **Before.** Two rule stores can refuse a call and each spelled it its own way: `tool "X" is denied by a session rule`, `denied by permission rule "R" (layer)`, plus four further shapes for the session lane's containment arms (`write-capable tool "X" denied: …`, `write to "p" is outside …`), and a session `commandDeny` that came out as the coarse policy's generic `command "curl" is denied by policy`. The resumed-execution re-check asserted `a session rule` for a fold that also holds inherited ancestor rules, the frozen constraint projections and the skill scope. A consumer could not read which store had spoken — the confusion that produced the wrong answer to "is the rule store re-read at every gate?".
|
|
66
|
+
- **After.** `src/core/rule-denial-sentence.ts` mints them all: `[tool "<tool>" is ]denied by <source>[ "<rule>"][ (<origin>)][: <detail>]`, over a closed source vocabulary derived from its own phrase table (`session` / `permission` / `narrowing`). The seven session-lane exits now differ only in the `detail` that says WHY; the session `commandDeny` refusal is re-minted as the session lane's own, carrying the coarse policy's sentence as its detail; the resumed re-check names the fold instead of one of its layers.
|
|
67
|
+
- **What a reader keyed on the old text sees.** The permission lane's sentence gained a head and kept its tail byte-for-byte (`denied by permission rule "Bash(rm -r:*)" (settings.permissions.deny)` still `toContain`s). The session lane's plain tool-name denial is unchanged. The containment arms and the allowlist miss are REWORDED and keep their diagnostic substrings (`cannot be path-confined`, `declares no path target`, `no resolvable path`, `outside the session-rule allowDirs`, `not in the session-rule allowlist`). The ASK sentence (`flagged by permission rule …`) is deliberately untouched: an ask is a question on a card, not a refusal in a tool result.
|
|
68
|
+
- **Reach.** No wire key, code, closed-set member or export moves; `PermissionResult.message` is a rendered string and this is a rendering change. `@cli` renders these in tool results — a fence pinned on a session-lane containment wording should re-pin on the substrings above. `test/backlog978-rule-denial-sentence.test.ts` pins the grammar, the two lanes' agreement on it, every session exit, the mint's composition, and a source scan holding the denial spellings to one module (its ceiling is stated: code lines only, and the ask / non-rule-store `denied by policy` faces are out of scope).
|
|
69
|
+
|
|
70
|
+
### A host can write a standing deny in one step, and core mints its provenance (#980; P2; 3 new exports + 1 BACKEND-CONTRACT union member; @server @cli)
|
|
71
|
+
- **Before.** The only way to land a persisted rule was the approval-record legs (prepare → confirm → redeem), and an add's provenance is DERIVED from the record's kind (`originOfRecordKind`). A host that wanted to write one standing deny on a person's own instruction had to borrow an IMPORT record, so the row came out stamped `imported-cc` — neither an import nor the other product, and a second, unverified story about where the rule came from.
|
|
72
|
+
- **After.** `addPersistedRule({ behavior, rule, scope, principal, provider })` — one step, no record, the dual of `removePersistedRule` and shaped like it (principal shorthand or the structural `RuleOwner`, the same scope and grammar validation, the same `failed`-only-after-read-back doctrine). `AddResult` mirrors `RemoveResult`: `added` (with the add's `dot`) / `no-op` (an equivalent rule is already live — no second dot) / `failed`.
|
|
73
|
+
- **Core mints the provenance; the entry takes no argument for it.** Every row this door writes is `origin: "user"`, so no host can claim a rule was imported or shipped as a starter. `RuleAddOrigin` is the same closed three-word set, `RuleAdd.origin` is still required, and a stored row is byte-identical to one the consent legs write — **zero new wire keys**.
|
|
74
|
+
- **The widening direction is not spellable.** `behavior` excludes `allow`: a standing approval is a person's yes and still takes a consent record. This is the asymmetry `removePersistedRule` has always stated — a host may narrow on a person's behalf, it may not widen — and a plain-JS caller that spells `allow` anyway meets a loud refusal rather than a silent write.
|
|
75
|
+
- **One add path, not two.** The redemption leg's optimistic write loop moved into a shared `landRuleAdd` both doors take (same 8 attempts, same read-then-CAS order, same refusal texts, same mixed-offer sibling gate judged on the anchoring snapshot), dispatching the two add arms exhaustively. `writer.apply` call sites in `src/` drop from three files to two.
|
|
76
|
+
- **`failed` now says how strong a claim it is, and two adversarial rounds set the line.** Round 1 found the read-back asking the LIVE view: an add that committed, lost its acknowledgement and was then covered by a concurrent removal read as "nothing was written" — and a caller believing that retries with a fresh dot and reinstates a restriction someone had just removed. Round 2 showed the raw face is not eternal either: a sync round carrying a collection frontier removes a tombstone together with the add dots it covered, leaving no trace at all. So the outcome is split by WHERE the failure happened. When the store ANSWERED — a read that failed before any write, a refused preflight, an epoch seal, a compare-and-set that lost all eight attempts — nothing landed and the text says so plainly, with no read-back. When the apply THREW, the read-back asks the raw face and reports `added` on a dot it finds; a dot it cannot find is **indeterminate**, never a confirmed nothing. Same-class note for the owner: `removePersistedRule`'s own "read-back confirms no tombstone was written" carries the same latent over-claim (a tombstone is collectable too) and is deliberately left unchanged here.
|
|
77
|
+
- **BREAKING (backend contract), three sentences.** (1) **What changed:** `RuleWriteDelta` gains one member, `RuleTightenAddDelta` (`kind: "tighten-add"`) — the true dual of `tighten-delete`: no authorization block, `behavior` excluding `allow` in the type; `redemption-add` is untouched, its `redemption` still required, and `allow` still rides it. (2) **Who is affected:** anyone implementing `PermissionRuleWriter.apply` OUT OF THIS REPO — their delta dispatch has no arm for the new kind; the failure is fail-closed and was measured both ways (a typed chain stops compiling and every add fails written-nothing; an untyped `if/else` chain — server's SQL twin's shape — reaches its delete arm, where `assertDeleteDeltaCarriesNoAdd` throws on the five add-only keys a tighten-add carries, before any tombstone is read). (3) **How to migrate:** add one arm with the same `assertWriteDeltaScopeDurable` + `foldDelta` as `redemption-add` and WITHOUT `assertRedemptionNotQuarantined` — that fence catches a replayed approval record, and a tightening add has no record to replay, which is a contract statement and not an omission.
|
|
78
|
+
- **Reach.** `@server` S-511 (`POST /v1/rules`, cli L-431②) is the named consumer: add the backend arm, switch the write off the borrowed import legs, and expect `origin: "user"`; the direction restriction it already applies to itself is now core's too. `@cli` reaches the store only through server. `@client-core` writes no rules. `docs/wire-consumption/unreleased-980.md` carries the table, including the `tighten-add` kind-reading rule and the quarantine-fence contract sentence. `test/backlog980-add-persisted-rule.test.ts` pins thirteen readings (including the import leg as a positive control that provenance was NOT unified) with a nine-row mutation table, two rows of which are measured by `tsc` rather than by a test run.
|
|
79
|
+
|
|
80
|
+
### A workflow agent's fleet row now says which attempt it is on (#979; P2; presence change on an existing wire key, additive; no new key, no export; @client-core @server)
|
|
81
|
+
- **Before.** `BackgroundChildEvent.seq` is contracted as "present whenever a cycle number is knowable, absent when no carrier exists". The `a*` delegation lanes have carried it on spawn / tick / terminal since #258; the `wa*` workflow lane carried it on NONE of its three frames — while it has held a carrier all along: the orchestration layer's stall/throttle retry re-runs the same logical agent on a fresh session under the SAME `wa*` id, which is exactly the "one row, two cycles" shape the field exists to separate. A fleet consumer folding rows by generation had a structurally unreachable third branch on this lane.
|
|
82
|
+
- **After.** The `wa*` row carries a generation: a fresh spawn is `1`, and each retry of the same agent bumps it, so a tick or terminal says which attempt produced it. The number is the ROW's, taken at emit — never copied from a forwarded descendant's frame, the same rule `agentType` and `model` already follow on this lane.
|
|
83
|
+
- **The carrier is the ATTEMPT counter, not the session rebind.** A retry also rebinds the row's transcript coordinate, and today the two move together — but the coordinate is a different fact (it is skipped for a spec shape that mints no id, and once the run is finalized), so counting rebinds would make the generation a property of whether a coordinate existed.
|
|
84
|
+
- **Reach.** `seq` is now ALWAYS present on a `wa*` row, where it was always absent; a consumer that read that absence as "this lane has no generation" must now read the number, and an absent `seq` on a `wa*` row dates the producer as pre-7.24.4. `task_progress.seq` is UNCHANGED and still absent for workflow agents (no `a*` registry row). `@client-core` B4 folds rows by generation; `@server` relays the frame unchanged; `@cli` has no `wa*` fleet renderer. `docs/wire-consumption/unreleased-979.md` carries the table, including the disclosure that one Workflow fan-out's children all share ONE `parentToolCallId` (up to `maxAgents`, default 50) — `seq` separates one row's cycles, not two rows. `test/backlog979-workflow-bce-seq.test.ts` pins the six readings with a five-row mutation table.
|
|
4
85
|
|
|
5
86
|
## 7.24.3 — 2026-09-20
|
|
6
87
|
|
|
@@ -30,6 +111,25 @@
|
|
|
30
111
|
- **Two operand readers are exported from `bash-readonly-classifier.ts`** for the plan (`readVerbOptionSlot`, `slotValueCandidates`, `attachedOptionPayloads`, `segmentSelectsRecursiveRead`, `inlineProgramClause`, …): internal modules, not on the package's export surface (`handoff:diff` v7.24.2 → HEAD: +0 −0).
|
|
31
112
|
- Reach: @server / @cli / @client-core zero; headless zero.
|
|
32
113
|
|
|
114
|
+
### The teammate key rides every carrier; a child's "main" parks in its leader's durable box; the revive arms address the root (#969 S0; minor; additive keys ×6, widening ×2, narrowing ×1; @server @cli @client-core)
|
|
115
|
+
- **The key.** `Agent({name})` was the teammate identity on three faces (the roster row, the SendMessage name rung, the uplink `from`) and absent from the four a consumer actually joins on. It now rides them all, as `spawnName`: `task_progress` (running + terminal ticks, beside the description-backed `name`), `BackgroundChildEvent` (spawn / tick / terminal — one stamp per lane closure, revival cycles included), `HookInvocationIdentity` (every hook seat of the leg and both `DelegationLifecycleEvent` frames — with `agentType`, the RESOLVED type word, so a hook bridge composes `SubagentStart.agent_type` from the type and not from the description). Absent everywhere at once for an unnamed delegation. Rules: +0 — the same word at one more mint per carrier.
|
|
116
|
+
- **The park seat.** `routeOrParkNotification` is where a frame addressed to a session with no live run waits; it had one backend (the in-process store). It now has two, chosen by the frame: a PEER'S WORDS (the `agentMessage` carrier — a child's `SendMessage("main")`) go to the session's DURABLE box when the session's own leg mounted the session-box drain (a top-level leg over a mailbox declaring `crossProcessSafe: true`, grammatical session id — `Prepared.sessionBox`, bound by the notification lane at every mount), as the same `peer_message` record the cross-session lane appends, with `peerMeta.fromRoot` = the box owner; every other frame keeps the in-process park. A LIVE leader still receives the message queued as `<agent-message>` — only the idle case moved. An append fault falls back to the in-process park and is disclosed. Rules: +1 backend selection, +1 carrier discrimination (the second retires when S1 gives engine frames a codec — `KL-969b`).
|
|
117
|
+
- **Same tree = same admission.** The box drain applies the cross-session parity judgment (hold for a human / refuse by setting) to a PEER's words; a record whose `fromRoot` is this session is its own teammate's and skips it — the admission gate (rate / dedup / hop) still runs. Without this a bypass-mode leader would HOLD every teammate message that reached it through the box. `MailboxPeerMeta.fromRoot` is a new typed key (`readMailboxPeerMeta` admits it; a pre-7.25 core reading a box holding one fails the lease closed — `KL-969c`). Rules: +1.
|
|
118
|
+
- **The drain mounts on one judgment.** `judgeSessionBoxDrain` (mailbox cross-process-safe ∧ grammatical id ∧ NOT a delegated child) is what both the drain binding and the park-seat binding read, so "parked here" and "drained there" agree by construction. Two widenings: a top-level leg now drains its box WITHOUT a peer directory (the directory only adds the discovery faces); a delegated child leg never drains one (its addressable box is its `a*` handle; a drain per child on a box nobody writes was a lease claim per turn per agent — `KL-969g`). `config.peer_lane_unmounted` keeps its audience.
|
|
119
|
+
- **The receipt speaks the outcome and names the session.** The uplink callback (`SendMessageToolOptions.uplink` / `RunInternals.parentNotify` / `ToolExecuteContext.onTaskNotification`) now RETURNS the injector's outcome on the engine's own wiring (`NotificationInjectionOutcome`, +1 member `parked_durably`; host-built callbacks may answer anything and read as "not reported" — the parameter type is `unknown`, so no host callback stops compiling); `SendMessage("main")` reads it: `queued for session <id>` (accepted into a live lane, not yet read) / `parked durably for main (session <id>)` (claimed only after the append settled) / `parked in process …` / `not re-sent …`, with `details.disposition` and `details.session`. The session is `RunInternals.uplinkSessionId`, the ONE address `main` names, pair-minted with the injector.
|
|
120
|
+
- **The revive arms — NARROWING.** A child revived by a SIBLING child, or by a host verb with no reviver session, addresses `main` to the row's ROOT session and carries no injector: a sibling is a peer the child reaches by name, not its main, and neither reviver holds a callback into the root. Such a child's `SendMessage("main")` reads `main_not_deliverable` (naming the session); before, it was delivered live into the sibling reviver's model. The child's terminal still reaches the root through the root-anchor arm; the sibling's own completion notification is unchanged. A top-level reviver keeps its injector (it asked for the work and holds the only live lane).
|
|
121
|
+
- **Reach.** @server: SQL mailbox is cross-process-safe ⇒ upgrade day every top-level run drains its own session box (one lease claim per turn boundary) and an idle leader's teammate messages become durable; whitelist `task_progress.spawnName`; map `BackgroundChildEvent.spawnName` → fleet `agentName`; hook bridge reads `identity.spawnName` / `identity.agentType`; `parked_durably` where the injector outcome is switched on. @cli: FileMailboxStore is cross-process-safe ⇒ same drain; the cross-session frame renders with the existing folding control; the fleet label shows the spawn name once client-core passes it. @client-core: `panel_task` tick `spawnName`; `subagent_lifecycle.agentType` from the type word. Headless one-shot runs: a box nobody re-runs is aged by the mailbox retention law, not woken (`KL-969d`). Full table: `docs/wire-consumption/unreleased-969.md`; limits `KL-969a`–`KL-969h`.
|
|
122
|
+
|
|
123
|
+
### Spec-level hooks reach the teammate leg; the PeerDirectory acceptance kit (#969 S1; widening; @server)
|
|
124
|
+
- `ParentSpecSeat.hooks`: a parent's `TaskSpec.hooks` (the user's settings hooks a host mounts on the request) now ride the seat to its delegated children under the one seat law (a child's own value wins, the seat fills an unset one) — a user's `stop` seat fires on the teammate leg with the child's identity, which is CC's `SubagentStop` from the same settings. `RunnerDeps.hooks` already served every leg; only the spec-level seams stopped at the leader. A deployment that assumed "leader only" discriminates on `identity.isDelegatedChild`.
|
|
125
|
+
- `peerDirectoryContract` (+ `PeerDirectoryContractHooks`, `PEER_DIRECTORY_CONTRACT_SCOPE`): the four readings the engine relies on from a host's directory — scope wall, the full set as copies (live + dead + deleted), the engine's resolver over the listing, `restorePeerSession` CAS-on-absent — as a kit with discriminating power; core's in-memory directory passes it; a server session-table directory passes it before it is wired.
|
|
126
|
+
- Eight rows in `docs/KNOWN-LIMITS.md` (`KL-969a`–`KL-969h`): the four in-process parking points this release does not touch, the terminal frame's in-process park pending a codec, the rolling-upgrade note for `fromRoot`, the headless box with no reader, the same-session two-replica double delivery, the three hook-event gaps against CC 2.1.259, the child leg's undrained box, and `fromRoot` being the seat's statement rather than a proof.
|
|
127
|
+
|
|
128
|
+
### `teamName` retired from the background-agent row (#969 S2; BREAKING on the type face; @server @cli @client-core @sdk)
|
|
129
|
+
- **Who is hurt.** `BackgroundAgentRecord.teamName?` and `RegisterBackgroundAgentInput.teamName?` are removed — a dead column: two copy sites forwarded it and no site in `src/` ever minted a value. Readers of the core row: server 0 (`record_json` passthrough), cli 0 (its own `teamName` is its CC fork's, not this row's), client-core 0, sdk 0.
|
|
130
|
+
- **Compensation.** None needed — the column never held a value; a hand-written row holding the key still reads back unchanged (`backgroundAgentStoreContract` gains the case). The team key is `rootSessionId`, already on every roster row, registry row, fleet spawn frame and durable record: one implicit team per session (CC 2.1.259 marks `team_name` deprecated / ignored).
|
|
131
|
+
- **Contract sentence.** `task_list.partition_is_root_session`: a deployment that mounts a `TaskListStore` per session keys it by the delegation tree's ROOT session — every child of the tree (spawned live, or revived by a host verb) reads and writes the same list. In-engine children inherit the leader's tool objects and share by construction; a host's revive leg that resolves a store per call is the divergence this sentence names.
|
|
132
|
+
|
|
33
133
|
## 7.24.2 — 2026-09-20
|
|
34
134
|
|
|
35
135
|
### A query whose separators are not the deployment's has no names to speak (#974; P1; security-facing narrowing of a rendered string; no wire key; @server @cli @client-core)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type MailboxCrossProcessVerdict, type MailboxStore } from "../core/mailbox-store.js";
|
|
2
|
-
import type { SystemInjectionPriority, TaskNotificationPayload } from "../core/task-notification.js";
|
|
2
|
+
import type { SessionBoxBinding, SystemInjectionPriority, TaskNotificationPayload } from "../core/task-notification.js";
|
|
3
3
|
import { type EngineNotice, type RunnerDeps } from "../core/types.js";
|
|
4
4
|
import { type PermissionModeClass } from "./cross-session-envelope.js";
|
|
5
5
|
import { type CrossSessionInboundSettingLayers } from "./cross-session-judge.js";
|
|
@@ -36,6 +36,21 @@ export declare function judgePeerLaneMount(deps: {
|
|
|
36
36
|
peerDirectory?: PeerDirectory;
|
|
37
37
|
mailboxStore?: MailboxStore;
|
|
38
38
|
}, sessionId?: string): PeerLaneMountVerdict | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* May THIS leg drain its own session box, and where is that box? The ONE mount judgment of the
|
|
41
|
+
* session-box drain, read by two seats: the wiring station binds the drain on it, the notification
|
|
42
|
+
* lane binds the park seat on it ({@link SessionBoxBinding}) — so a frame is parked into a box exactly
|
|
43
|
+
* when a leg of that session drains it. Independent of the peer DIRECTORY (the directory adds the
|
|
44
|
+
* discovery faces; the box is also where a child's `SendMessage("main")` waits while its host session
|
|
45
|
+
* is idle). A DELEGATED CHILD never drains one: its addressable box is its `a*` handle (the tier-3
|
|
46
|
+
* revival lane), and a drain per child on a box nobody writes would cost a lease claim per turn on
|
|
47
|
+
* every background agent. `undefined` on every refusal — silently: no host asked for a box here.
|
|
48
|
+
*/
|
|
49
|
+
export declare function judgeSessionBoxDrain(deps: Pick<RunnerDeps, "mailboxStore" | "onError">, leg: {
|
|
50
|
+
sessionId: string;
|
|
51
|
+
scope: string;
|
|
52
|
+
isDelegatedChild: boolean;
|
|
53
|
+
}): SessionBoxBinding | undefined;
|
|
39
54
|
/** The disposition arms of a `peer.inbound_disposition` notice (closed set; `detail.disposition`). */
|
|
40
55
|
export type PeerInboundDisposition = "refused" | "held" | "admission_refused" | "notice_unrouted" | "notice_user_only";
|
|
41
56
|
export interface PeerSessionDrainOptions {
|
|
@@ -32,6 +32,20 @@ export function judgePeerLaneMount(deps, sessionId) {
|
|
|
32
32
|
}
|
|
33
33
|
return { ok: true };
|
|
34
34
|
}
|
|
35
|
+
export function judgeSessionBoxDrain(deps, leg) {
|
|
36
|
+
const mailbox = deps.mailboxStore;
|
|
37
|
+
if (mailbox === undefined || leg.isDelegatedChild)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (!mailboxCrossProcessMountVerdict(mailbox).ok || !isPeerSessionId(leg.sessionId))
|
|
40
|
+
return undefined;
|
|
41
|
+
return {
|
|
42
|
+
mailbox,
|
|
43
|
+
scope: leg.scope,
|
|
44
|
+
handle: peerSessionBoxHandle(leg.sessionId),
|
|
45
|
+
sessionId: leg.sessionId,
|
|
46
|
+
onError: (e) => deps.onError?.(e, { phase: "degraded", sessionId: leg.sessionId, classification: "session-box-park" }),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
35
49
|
export function createPeerSessionDrain(opts) {
|
|
36
50
|
const { mailbox, sessionId, runId, scope } = opts;
|
|
37
51
|
const boxHandle = peerSessionBoxHandle(sessionId);
|
|
@@ -287,7 +301,8 @@ export function createPeerSessionDrain(opts) {
|
|
|
287
301
|
settleNoticeRecord(facts, m, meta);
|
|
288
302
|
continue;
|
|
289
303
|
}
|
|
290
|
-
|
|
304
|
+
const sameTree = meta?.fromRoot !== undefined && meta.fromRoot.toLowerCase() === sessionId.toLowerCase();
|
|
305
|
+
if (!sameTree) {
|
|
291
306
|
const judged = judgeMeta(facts, meta);
|
|
292
307
|
if (judged.verdict === "refuse") {
|
|
293
308
|
disclose("refused", judged.cause, m);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import type { RunnerSelfSeat } from "../core/runner/contracts.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type TaskNotificationPayload } from "../core/task-notification.js";
|
|
4
4
|
import { type ToolCtxEnricher } from "../core/tools.js";
|
|
5
5
|
import { type TaskAccess } from "../core/task-registry.js";
|
|
6
6
|
import type { PermissionModeClass } from "./cross-session-envelope.js";
|
|
@@ -69,7 +69,15 @@ export interface SendMessageToolOptions {
|
|
|
69
69
|
*/
|
|
70
70
|
uplink?: (n: TaskNotificationPayload, opts?: {
|
|
71
71
|
priority?: "now" | "next" | "later";
|
|
72
|
-
}) =>
|
|
72
|
+
}) => unknown;
|
|
73
|
+
/**
|
|
74
|
+
* The session `"main"` names for this run ({@link import("../core/runner/contracts.js").RunInternals.uplinkSessionId}
|
|
75
|
+
* — the spawner's, or the reviver's, or on a sibling/host revival the tree root's). The uplink
|
|
76
|
+
* receipt names it so the model knows WHICH conversation "main" is; its presence without
|
|
77
|
+
* {@link uplink} marks a delegated child whose main has no live lane from here (the honest
|
|
78
|
+
* not-deliverable text names the session). Absent on a top-level run.
|
|
79
|
+
*/
|
|
80
|
+
uplinkSessionId?: string;
|
|
73
81
|
/** design/147 S1a — this run's own spawn name (teammate identity) for uplink attribution. */
|
|
74
82
|
senderName?: string;
|
|
75
83
|
/** design/147 S1c — the deployment's durable roster, consulted after the live registry misses. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
+
import { isNotificationInjectionOutcome } from "../core/task-notification.js";
|
|
2
3
|
import { defineTool } from "../core/tools.js";
|
|
3
4
|
import { normalizeAgentName, DURABLE_AGENT_HANDLE_RE, DURABLE_AGENT_HEARTBEAT_MS } from "../core/task-registry.js";
|
|
4
5
|
import { announceTranscriptIntegrityGapOnce, canAccessAgentRecord, clearRevivedRowTerminalPayload } from "../core/background-agent-store.js";
|
|
@@ -198,7 +199,7 @@ export function createSendMessageTool(opts) {
|
|
|
198
199
|
const senderId = ctx.taskId ?? opts.owner;
|
|
199
200
|
const [parentTaskId, parentSessionId] = ctx.parentTaskId !== undefined ? [ctx.parentTaskId, ctx.parentSessionId] : [opts.parentTaskId, opts.parentSessionId];
|
|
200
201
|
const senderName = ctx.spawnedAgentName ?? opts.senderName;
|
|
201
|
-
const senderIsChild = parentTaskId !== undefined || opts.uplink !== undefined || senderName !== undefined || opts.siblingRetain !== undefined;
|
|
202
|
+
const senderIsChild = parentTaskId !== undefined || opts.uplink !== undefined || opts.uplinkSessionId !== undefined || senderName !== undefined || opts.siblingRetain !== undefined;
|
|
202
203
|
const senderLabel = senderName ?? (senderIsChild ? senderId ?? "main" : "main");
|
|
203
204
|
if (senderId !== undefined && isObserverTaskId(senderId)) {
|
|
204
205
|
return { content: OBSERVER_SENDMESSAGE_SENDER_REFUSAL, details: { error: "observer_sender" }, isError: true };
|
|
@@ -414,25 +415,35 @@ export function createSendMessageTool(opts) {
|
|
|
414
415
|
}
|
|
415
416
|
const uplinkBody = message.length > UPLINK_RESULT_MAX ? `${message.slice(0, UPLINK_RESULT_MAX)}\n[uplink truncated: ${message.length} chars total — read the agent's transcript for the rest]` : message;
|
|
416
417
|
const uplinkSeq = ++uplinkSeqGlobal;
|
|
418
|
+
let outcome;
|
|
417
419
|
try {
|
|
418
|
-
opts.uplink({
|
|
420
|
+
const reported = await opts.uplink({
|
|
419
421
|
task_id: senderId,
|
|
420
422
|
task_type: "background_agent",
|
|
421
423
|
status: "event",
|
|
422
424
|
summary: `message from ${senderLabel}: ${summary}`,
|
|
423
425
|
result: `${uplinkBody}\n\n${PEER_MESSAGE_NOTICE}`,
|
|
424
426
|
seq: uplinkSeq,
|
|
425
|
-
peer: { hopChain: prospectiveChain },
|
|
427
|
+
peer: { hopChain: prospectiveChain, ...(directMountSessionId !== undefined ? { fromSession: directMountSessionId } : {}), ...(senderKey !== undefined ? { senderKey } : {}) },
|
|
426
428
|
agentMessage: { from: senderLabel, body: uplinkBody },
|
|
427
429
|
_sema_provenance: { kind: "agent_message", from: senderLabel, taskId: senderId, seq: uplinkSeq },
|
|
428
430
|
}, { priority: "next" });
|
|
431
|
+
outcome = isNotificationInjectionOutcome(reported) ? reported : undefined;
|
|
429
432
|
}
|
|
430
433
|
catch (e) {
|
|
431
434
|
return { content: `Message not sent: the parent's notification lane rejected it (${thrownText(e)}). ${DEDUP_RETRY_NOTE}`, details: { error: "uplink_failed", to }, isError: true };
|
|
432
435
|
}
|
|
436
|
+
const mainLabel = opts.uplinkSessionId !== undefined ? `session ${inlineUntrusted(opts.uplinkSessionId, 96)}` : "the spawning conversation";
|
|
437
|
+
const content = outcome === "parked_durably"
|
|
438
|
+
? `Message parked durably for main (${mainLabel}): it has no run in progress, so the message was appended to its durable session box and is read at the start of its next run (it survives a restart). Continue with your task; do not wait for a reply.`
|
|
439
|
+
: outcome === "parked"
|
|
440
|
+
? `Message parked in process for main (${mainLabel}): it has no run in progress, so the message waits in this process for its next run — a restart of this process loses it. Continue with your task; do not wait for a reply.`
|
|
441
|
+
: outcome === "dropped_duplicate"
|
|
442
|
+
? `Message not re-sent to main (${mainLabel}): it already delivered this exact message. Continue with your task; do not resend the same content.`
|
|
443
|
+
: `Message sent to main — queued for ${mainLabel} at its next turn boundary (accepted into its lane, not yet read). If that run finishes before reading it, the message may not survive. Continue with your task; do not wait for a reply.`;
|
|
433
444
|
return {
|
|
434
|
-
content
|
|
435
|
-
details: { type: "send-message", status: "uplinked", to: "main", seq: uplinkSeq },
|
|
445
|
+
content,
|
|
446
|
+
details: { type: "send-message", status: "uplinked", to: "main", seq: uplinkSeq, ...(outcome !== undefined ? { disposition: outcome } : {}), ...(opts.uplinkSessionId !== undefined ? { session: opts.uplinkSessionId } : {}) },
|
|
436
447
|
};
|
|
437
448
|
}
|
|
438
449
|
if (!senderIsChild) {
|
|
@@ -443,9 +454,9 @@ export function createSendMessageTool(opts) {
|
|
|
443
454
|
};
|
|
444
455
|
}
|
|
445
456
|
return {
|
|
446
|
-
content: `Message not sent: "main" (the spawning conversation) is not a deliverable target here. ` +
|
|
457
|
+
content: `Message not sent: "main" (${opts.uplinkSessionId !== undefined ? `session ${inlineUntrusted(opts.uplinkSessionId, 96)}` : "the spawning conversation"}) is not a deliverable target from this run — no live lane into it is wired here. ` +
|
|
447
458
|
`Your completion is reported to it automatically — finish your task and your final report will be relayed.`,
|
|
448
|
-
details: { error: "main_not_deliverable", to },
|
|
459
|
+
details: { error: "main_not_deliverable", to, ...(opts.uplinkSessionId !== undefined ? { session: opts.uplinkSessionId } : {}) },
|
|
449
460
|
isError: true,
|
|
450
461
|
};
|
|
451
462
|
}
|
package/dist/agents/subagent.js
CHANGED
|
@@ -597,7 +597,7 @@ export function createSubagentResume(deps) {
|
|
|
597
597
|
if (deps.bgSink !== undefined) {
|
|
598
598
|
reviveEmit = (event) => {
|
|
599
599
|
try {
|
|
600
|
-
deps.bgSink(event);
|
|
600
|
+
deps.bgSink(deps.rowName !== undefined ? { ...event, spawnName: deps.rowName } : event);
|
|
601
601
|
}
|
|
602
602
|
catch {
|
|
603
603
|
}
|
|
@@ -2104,6 +2104,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2104
2104
|
parentToolCallId: ctx.toolCallId,
|
|
2105
2105
|
...(childAgentName ? { agentName: childAgentName } : {}),
|
|
2106
2106
|
...(agentName !== undefined ? { explicitAgentName: agentName } : {}),
|
|
2107
|
+
spawnAgentType,
|
|
2107
2108
|
...(reviveClaim !== undefined
|
|
2108
2109
|
? {
|
|
2109
2110
|
...(reviveClaim.row.parentTaskId !== undefined ? { parentTaskId: reviveClaim.row.parentTaskId } : {}),
|
|
@@ -2120,12 +2121,15 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2120
2121
|
}),
|
|
2121
2122
|
...(ctx.centerArtifactDigest !== undefined ? { parentCenterArtifactDigest: ctx.centerArtifactDigest } : {}),
|
|
2122
2123
|
...(ctx.centerSourceRevision !== undefined ? { parentCenterSourceRevision: ctx.centerSourceRevision } : {}),
|
|
2123
|
-
...(
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2124
|
+
...(() => {
|
|
2125
|
+
const viaRoot = reviveClaim !== undefined && (ctx.parentTaskId !== undefined || ctx.sessionId === undefined);
|
|
2126
|
+
const address = viaRoot ? (reviveClaim.row.rootSessionId ?? reviveClaim.row.parentSessionId) : ctx.sessionId;
|
|
2127
|
+
const injector = viaRoot ? undefined : ctx.onTaskNotification;
|
|
2128
|
+
return {
|
|
2129
|
+
...(address !== undefined ? { uplinkSessionId: address } : {}),
|
|
2130
|
+
...(injector !== undefined ? { parentNotify: injector, ...(ctx.peerSelfRef !== undefined ? { parentPeerRef: ctx.peerSelfRef } : {}) } : {}),
|
|
2131
|
+
};
|
|
2132
|
+
})(),
|
|
2129
2133
|
...(ctx.subagentRetain !== undefined ? { parentRetainLedger: ctx.subagentRetain } : {}),
|
|
2130
2134
|
onForwardEvent: recordingForward,
|
|
2131
2135
|
...(worktreeDir !== undefined
|
|
@@ -2538,7 +2542,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2538
2542
|
const bgSink = ctx.onBackgroundChildEvent;
|
|
2539
2543
|
const sinkEmit = (event) => {
|
|
2540
2544
|
try {
|
|
2541
|
-
bgSink?.(event);
|
|
2545
|
+
bgSink?.(agentName !== undefined ? { ...event, spawnName: agentName } : event);
|
|
2542
2546
|
}
|
|
2543
2547
|
catch {
|
|
2544
2548
|
}
|
|
@@ -3019,7 +3023,6 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3019
3023
|
? { rootSessionId: reviveRow.rootSessionId ?? reviveRow.parentSessionId }
|
|
3020
3024
|
: {}),
|
|
3021
3025
|
...(reviveRow.model !== undefined ? { model: reviveRow.model } : {}),
|
|
3022
|
-
...(reviveRow.teamName !== undefined ? { teamName: reviveRow.teamName } : {}),
|
|
3023
3026
|
deliveryChannel: "attaching",
|
|
3024
3027
|
toolUseId: ctx.toolCallId,
|
|
3025
3028
|
...(bg.agentStore !== undefined ? { store: bg.agentStore, durableSeed: { record: reviveClaim.row, rev: reviveClaim.rev } } : {}),
|
|
@@ -3079,7 +3082,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3079
3082
|
const bgSink = ctx.onBackgroundChildEvent;
|
|
3080
3083
|
const sinkEmit = (event) => {
|
|
3081
3084
|
try {
|
|
3082
|
-
bgSink?.(event);
|
|
3085
|
+
bgSink?.(agentName !== undefined ? { ...event, spawnName: agentName } : event);
|
|
3083
3086
|
}
|
|
3084
3087
|
catch {
|
|
3085
3088
|
}
|
|
@@ -10,7 +10,7 @@ export interface BackgroundAgentUsage {
|
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
12
|
* One durable background-agent execution row. Field discipline (design/151 §3.1, HIGH-4): revival
|
|
13
|
-
* re-hydration data is LOOKUP KEYS ONLY (`agentType`/`model`/`
|
|
13
|
+
* re-hydration data is LOOKUP KEYS ONLY (`agentType`/`model`/`name` — the CC 2.1.216
|
|
14
14
|
* `.meta.json` sidecar shape) — never a serialized spec and never closures; a tier-3 revival rebuilds
|
|
15
15
|
* the spec the same way a fresh named-teammate spawn would (roster/agent definition + the reviver's
|
|
16
16
|
* deployment assembly).
|
|
@@ -40,8 +40,8 @@ export interface BackgroundAgentRecord {
|
|
|
40
40
|
name?: string;
|
|
41
41
|
agentType?: string;
|
|
42
42
|
description?: string;
|
|
43
|
-
/** Revival lookup
|
|
44
|
-
|
|
43
|
+
/** Revival lookup key (CC meta-sidecar parity); populated when known, never required. The team
|
|
44
|
+
* key is {@link rootSessionId} — one implicit team per session, no column of its own. */
|
|
45
45
|
model?: string;
|
|
46
46
|
/** design/151 §3.1d (HIGH-3): the WRITING process instance id (random per registry instance). A
|
|
47
47
|
* fallback read of a `running` row whose writer is not the reading instance serves an HONEST
|
|
@@ -105,6 +105,11 @@ export interface BackgroundChildEvent {
|
|
|
105
105
|
progressParentTaskId?: string;
|
|
106
106
|
/** tick: the child's display name, when the frame carried one. */
|
|
107
107
|
name?: string;
|
|
108
|
+
/** ALL kinds: the EXPLICIT `Agent({name})` spawn identity, when the spawn carried one — the teammate
|
|
109
|
+
* KEY every carrier of this child spells the same way (`task_progress.spawnName`, the roster row's
|
|
110
|
+
* `name`, the uplink `from` label), so a fleet consumer binds "@name → row" by string equality
|
|
111
|
+
* without reading the description-backed {@link name}. ABSENT = an unnamed delegation. */
|
|
112
|
+
spawnName?: string;
|
|
108
113
|
/**
|
|
109
114
|
* tick (residual observability, lane B): the child's most recent tool intent as one human line ("Bash npm test",
|
|
110
115
|
* "Edit src/x.ts") — derived from the last `tool_start` on the child's stream, so a parent can show
|
package/dist/core/gate-fold.js
CHANGED
|
@@ -48,6 +48,24 @@ export async function runGateFold(pass) {
|
|
|
48
48
|
...(pass.hookAsk.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
|
+
if ((pass.decision.action === "allow" || pass.decision.action === "ask") && input.shellReadCheck !== undefined) {
|
|
52
|
+
const station = input.shellReadCheck;
|
|
53
|
+
const judged = station.judge(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput, input.trackedCwd?.(), pass.callSignal ?? input.abortSignal, { toolCallId });
|
|
54
|
+
if (judged !== null) {
|
|
55
|
+
let verdict;
|
|
56
|
+
try {
|
|
57
|
+
verdict = await withGateTimeout(judged, boundedAwaitDeadlineMs(), pass.callSignal ?? input.abortSignal, "shellReadStation");
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const kind = gateWaitFailureOf(err);
|
|
61
|
+
if (kind !== "aborted" && input.abortSignal?.aborted !== true) {
|
|
62
|
+
traceHookCrash(input, new Error(`shell read station ${kind === "timeout" ? "timed out" : "threw"} for tool "${toolName}" (toolCallId ${toolCallId}) — where this call reads could not be established: ${thrownText(err)}`), notifier);
|
|
63
|
+
}
|
|
64
|
+
verdict = station.unanswered(toolName, kind === "timeout" ? "the read station timed out" : "the read station could not answer");
|
|
65
|
+
}
|
|
66
|
+
applyShellReadVerdict(pass, verdict, toolName, input.shellGated === true);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
51
69
|
if (input.egress && pass.decision.action === "allow") {
|
|
52
70
|
pass.decision = {
|
|
53
71
|
action: "ask",
|
|
@@ -59,6 +77,14 @@ export async function runGateFold(pass) {
|
|
|
59
77
|
const alreadyAsking = pass.decision.action === "ask";
|
|
60
78
|
if ((pass.decision.action === "allow" || alreadyAsking) && (input.irreversibility === "always" || input.irreversibility === "maybe")) {
|
|
61
79
|
let tighten = !alreadyAsking;
|
|
80
|
+
const withdrawStationOffer = () => {
|
|
81
|
+
if (alreadyAsking && pass.shellReadOffer === true && pass.decision.action === "ask" && pass.decision.probeReadRootCandidate !== undefined) {
|
|
82
|
+
const { probeReadRootCandidate: _withdrawn, ...kept } = pass.decision;
|
|
83
|
+
pass.decision = kept;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
if (input.irreversibility === "always" || !input.reversibilityProbe)
|
|
87
|
+
withdrawStationOffer();
|
|
62
88
|
let probeReason;
|
|
63
89
|
let probeCause;
|
|
64
90
|
let probeMandated;
|
|
@@ -80,11 +106,13 @@ export async function runGateFold(pass) {
|
|
|
80
106
|
if (verdict?.boundaryDeclined === true)
|
|
81
107
|
pass.probeBoundaryDeclined = true;
|
|
82
108
|
probeReadRootCandidate = normalizeReadRootCandidate(verdict?.readRootCandidate);
|
|
109
|
+
withdrawStationOffer();
|
|
83
110
|
}
|
|
84
111
|
}
|
|
85
112
|
catch (err) {
|
|
86
113
|
reversible = false;
|
|
87
114
|
pass.probeUnanswered = true;
|
|
115
|
+
withdrawStationOffer();
|
|
88
116
|
if (input.abortSignal?.aborted !== true)
|
|
89
117
|
traceHookCrash(input, err, notifier);
|
|
90
118
|
}
|
|
@@ -140,3 +168,60 @@ export async function runGateFold(pass) {
|
|
|
140
168
|
}
|
|
141
169
|
return await pass.afterFold(pass);
|
|
142
170
|
}
|
|
171
|
+
function shellReadAskMessage(toolName, finding) {
|
|
172
|
+
return finding === "outside_roots"
|
|
173
|
+
? `tool "${toolName}" reads outside the directories allowed for this session — explicit approval required`
|
|
174
|
+
: `tool "${toolName}" reads where the session's read boundary could not establish what is read — explicit approval required`;
|
|
175
|
+
}
|
|
176
|
+
function applyShellReadVerdict(pass, verdict, toolName, shellGated) {
|
|
177
|
+
const account = verdict.sentence !== undefined && verdict.sentence.trim().length > 0 ? inlineUntrusted(verdict.sentence, PROBE_REASON_MAX) : undefined;
|
|
178
|
+
switch (verdict.disposition) {
|
|
179
|
+
case "vouch":
|
|
180
|
+
return;
|
|
181
|
+
case "refuse":
|
|
182
|
+
pass.decision = { action: "deny", message: verdict.sentence ?? `tool "${toolName}" reads what the session's read boundary refuses` };
|
|
183
|
+
pass.deniedBy = "read_boundary";
|
|
184
|
+
if (verdict.details !== undefined)
|
|
185
|
+
pass.denyDetails = verdict.details;
|
|
186
|
+
return;
|
|
187
|
+
case "mandated": {
|
|
188
|
+
if (verdict.details !== undefined)
|
|
189
|
+
pass.shellReadDetails = verdict.details;
|
|
190
|
+
if (pass.decision.action !== "allow") {
|
|
191
|
+
if (pass.decision.action === "ask")
|
|
192
|
+
pass.decision = { ...pass.decision, probeMandated: true };
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const candidate = normalizeReadRootCandidate(verdict.readRootCandidate);
|
|
196
|
+
pass.decision = {
|
|
197
|
+
action: "ask",
|
|
198
|
+
message: shellReadAskMessage(toolName, verdict.finding),
|
|
199
|
+
decisionReason: "safety",
|
|
200
|
+
probeMandated: true,
|
|
201
|
+
...(account !== undefined ? { probeReason: account } : {}),
|
|
202
|
+
...(candidate !== undefined ? { probeReadRootCandidate: candidate } : {}),
|
|
203
|
+
};
|
|
204
|
+
if (candidate !== undefined)
|
|
205
|
+
pass.shellReadOffer = true;
|
|
206
|
+
pass.tightenedBy = shellGated ? "shell_gate" : "safety";
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
case "ordinary": {
|
|
210
|
+
if (pass.decision.action === "allow") {
|
|
211
|
+
pass.decision = {
|
|
212
|
+
action: "ask",
|
|
213
|
+
message: shellReadAskMessage(toolName, verdict.finding),
|
|
214
|
+
decisionReason: "safety",
|
|
215
|
+
...(account !== undefined ? { probeReason: account } : {}),
|
|
216
|
+
};
|
|
217
|
+
pass.tightenedBy = shellGated ? "shell_gate" : "safety";
|
|
218
|
+
}
|
|
219
|
+
pass.probeBoundaryDeclined = true;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
default: {
|
|
223
|
+
const exhaustive = verdict.disposition;
|
|
224
|
+
return exhaustive;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|