@junghanacs/entwurf 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +248 -0
  3. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +14 -9
  4. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +9 -6
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +11 -3
  7. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +66 -0
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +10 -1
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +8 -2
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +9 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +25 -13
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +14 -2
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +24 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +61 -7
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +64 -4
  17. package/mcp/entwurf-bridge/src/index.ts +16 -8
  18. package/package.json +3 -2
  19. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +3 -1
  20. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +13 -8
  21. package/pi-extensions/lib/entwurf-deliverability.ts +104 -0
  22. package/pi-extensions/lib/entwurf-fact-provider.ts +20 -6
  23. package/pi-extensions/lib/entwurf-facts.ts +52 -3
  24. package/pi-extensions/lib/entwurf-peer-observe.ts +77 -0
  25. package/pi-extensions/lib/entwurf-peers-render.ts +12 -1
  26. package/pi-extensions/lib/entwurf-v2-decider.ts +26 -8
  27. package/pi-extensions/lib/entwurf-v2-mailbox.ts +9 -2
  28. package/pi-extensions/lib/entwurf-v2-production.ts +32 -12
  29. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -6
  30. package/pi-extensions/lib/entwurf-v2-send.ts +26 -3
  31. package/pi-extensions/lib/entwurf-v2-surface.ts +24 -3
  32. package/pi-extensions/lib/meta-session.ts +76 -7
  33. package/pi-extensions/meta-bridge-hook.ts +72 -3
  34. package/run.sh +21 -0
  35. package/scripts/check-bridge-delivery.ts +42 -1
  36. package/scripts/check-entwurf-fact-provider.ts +22 -1
  37. package/scripts/check-entwurf-facts.ts +17 -2
  38. package/scripts/check-entwurf-peers-surface.ts +20 -2
  39. package/scripts/check-entwurf-self-address.ts +20 -2
  40. package/scripts/check-entwurf-v2-decider.ts +6 -3
  41. package/scripts/check-entwurf-v2-mailbox.ts +33 -0
  42. package/scripts/check-entwurf-v2-matrix.ts +10 -5
  43. package/scripts/check-entwurf-v2-production.ts +72 -1
  44. package/scripts/check-entwurf-v2-runner.ts +18 -0
  45. package/scripts/check-entwurf-v2-send.ts +13 -1
  46. package/scripts/check-entwurf-v2-surface.ts +75 -3
  47. package/scripts/check-gate-qualification.ts +1 -0
  48. package/scripts/check-hook-launch-topology.ts +70 -1
  49. package/scripts/check-mailbox-receipt-state.ts +6 -0
  50. package/scripts/check-meta-doctor-oracle.sh +73 -0
  51. package/scripts/check-meta-hook-session-switch.ts +560 -0
  52. package/scripts/check-meta-mailbox-state-write.ts +9 -2
  53. package/scripts/check-meta-manifest-schema.py +19 -1
  54. package/scripts/check-meta-session.ts +10 -2
  55. package/scripts/meta-bridge-doctor.sh +47 -7
  56. package/scripts/meta-bridge-state.py +23 -5
  57. package/scripts/meta-bridge-statusline.sh +65 -2
  58. package/scripts/mutants/meta-hook-session-switch.json +223 -0
  59. package/scripts/raw-async-delivery/README.md +280 -9
  60. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +6 -2
  61. package/scripts/raw-async-delivery/lab-statusline.sh +63 -0
  62. package/scripts/raw-async-delivery/mailbox-watch.py +230 -0
  63. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +3 -1
  64. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +13 -4
  65. package/scripts/raw-async-delivery/probe-delivery-transparency.sh +387 -0
  66. package/scripts/raw-claude-session-switch/README.md +170 -0
  67. package/scripts/smoke-acp-cortex-live.ts +22 -2
  68. package/scripts/smoke-acp-v2-send-live.ts +23 -2
  69. package/scripts/smoke-entwurf-chain-live.ts +85 -13
  70. package/scripts/smoke-entwurf-v2-matrix-live.ts +23 -1
  71. package/scripts/smoke-meta-async-drift.sh +31 -8
  72. package/scripts/smoke-meta-install-state.sh +170 -11
  73. package/scripts/smoke-meta-keyset-guard.sh +4 -1
  74. package/scripts/smoke-mux-fresh-call-live.ts +13 -3
@@ -36,16 +36,23 @@ import { enqueueMetaMessage } from "./meta-session.js";
36
36
  * legacy mailbox path used). `plan.wantsReply` is threaded into the body — v2 carries the
37
37
  * caller's intent, a DELIBERATE divergence from the legacy hard-coded `false`. An enqueue
38
38
  * throw PROPAGATES; it is never converted to `{success:false}`.
39
+ *
40
+ * #98 R — the SEND receipt: `enqueueMetaMessage` already returns the exact `.msg` path it
41
+ * wrote ("Returns the paths so a sender can show exactly what was queued"), and this body
42
+ * used to flatten it to `{success:true}`. It now carries that ONE field through. Nothing
43
+ * else from the enqueue result crosses: no timestamps, and specifically no `lastReadAt` —
44
+ * see the `RpcSendResult` note. The path is reported verbatim from the enqueue result, not
45
+ * re-derived from the plan, so it can never disagree with the file that was actually written.
39
46
  */
40
47
  export function executeMetaMailboxSend(plan, sender, deps) {
41
48
  const body = sender ? formatMetaMailboxBody(sender, plan.message, plan.wantsReply) : plan.message;
42
- deps.enqueue({
49
+ const enqueued = deps.enqueue({
43
50
  gardenId: plan.targetGardenId,
44
51
  body,
45
52
  sessionsDir: plan.sessionsDir,
46
53
  mailboxDir: plan.mailboxDir,
47
54
  });
48
- return { success: true };
55
+ return { success: true, messagePath: enqueued.messagePath };
49
56
  }
50
57
  /**
51
58
  * Build the production `sendViaMailbox(plan, lock)` adapter the 5c-2a send hand consumes.
@@ -29,7 +29,7 @@
29
29
  * claiming "no conflict". The decider's later `inspectSocket` probe is a SEPARATE step.
30
30
  */
31
31
  import { sendRpcCommand as realSendRpc, } from "./entwurf-control-rpc.js";
32
- import { mailboxConversationalDeliverable, receiverMarkerMatchesIdentity, } from "./entwurf-deliverability.js";
32
+ import { mailboxConversationalDeliverable, resolveMailboxReceiverFacts, } from "./entwurf-deliverability.js";
33
33
  import { isOutOfSocketDomainGardenIdConflict } from "./entwurf-facts.js";
34
34
  import { isLivenessSupported } from "./entwurf-v2-contract.js";
35
35
  import { decideDispatch, } from "./entwurf-v2-decider.js";
@@ -38,7 +38,7 @@ import { makeProductionSendViaMailbox } from "./entwurf-v2-mailbox.js";
38
38
  import { makeNativePushSend } from "./entwurf-v2-native-push.js";
39
39
  import { executeControlSocketSend, } from "./entwurf-v2-send.js";
40
40
  import { resolveDeadControlSendFallback } from "./entwurf-v2-send-fallback.js";
41
- import { defaultMetaMailboxDir, defaultMetaSessionsDir, enqueueMetaMessage, metaCapabilityFor, metaRecordExistsByGardenId, readAddressableMetaIdentity, readMetaReceiverMarker, } from "./meta-session.js";
41
+ import { defaultMetaMailboxDir, defaultMetaSessionsDir, enqueueMetaMessage, metaCapabilityFor, metaRecordExistsByGardenId, readAddressableMetaIdentity, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "./meta-session.js";
42
42
  import { resolveNativePushAdapter as realResolveNativePushAdapter, } from "./native-push/adapter.js";
43
43
  import { CONTROL_SOCKET_DIR, controlSocketPath, inspectControlSocketPath, inspectTargetControlSocket, isRecordLessSocketCandidate, } from "./socket-discovery.js";
44
44
  import { classifyConnectError, probeSocketLiveness } from "./socket-probe.js";
@@ -77,6 +77,8 @@ export function makeProductionEntwurfV2Deps(opts) {
77
77
  metaRecordExists: s.metaRecordExists ?? metaRecordExistsByGardenId,
78
78
  readIdentity: s.readIdentity ?? readAddressableMetaIdentity,
79
79
  readReceiverMarker: s.readReceiverMarker ?? ((gid) => readMetaReceiverMarker({ gardenId: gid })),
80
+ readSenderMarker: s.readSenderMarker ??
81
+ ((backend, ownerPid) => readMetaSenderMarker({ backend: requireBackend(backend), ownerPid })),
80
82
  inspectPath: s.inspectPath ?? inspectControlSocketPath,
81
83
  acquireLock: s.acquireLock ?? realAcquireLock,
82
84
  releaseLock: s.releaseLock ?? realReleaseLock,
@@ -98,22 +100,32 @@ export function makeProductionEntwurfV2Deps(opts) {
98
100
  enqueue: io.enqueue,
99
101
  });
100
102
  // ── ONE deliverability seam (SE-2 2d-3): wake-mode capability AND a live active-
101
- // receiver (a presence marker that matches THIS identity). The SAME closure is injected
102
- // into the decider AND the dead-fallback, so a direct send and a re-resolved fallback
103
- // send can never drift to different deliverability verdicts. recordBacked is true by
104
- // construction resolveTarget already proved the record exists before any unsupported-
105
- // backend mailbox route, and the closure is only consulted on that route. A null /
106
- // dead-owner / identity-mismatched marker is fail-closed to inactive (SE-2): a reply to a
107
- // terminated self-fetch citizen is rejected, not enqueued as mailbox garbage. ──────────
103
+ // receiver. The SAME closure is injected into the decider AND the dead-fallback, so a
104
+ // direct send and a re-resolved fallback send can never drift to different deliverability
105
+ // verdicts. recordBacked is true by construction resolveTarget already proved the record
106
+ // exists before any unsupported-backend mailbox route, and the closure is only consulted on
107
+ // that route. A null / dead-owner / identity-mismatched marker is fail-closed to inactive
108
+ // (SE-2): a reply to a terminated self-fetch citizen is rejected, not enqueued as mailbox
109
+ // garbage.
110
+ //
111
+ // The two receiver facts come from the SHARED composition (#101 결함 B), never from one
112
+ // value copied into both slots. `ownerAlive` is the marker↔identity match on a live owner;
113
+ // `watchArmed` is the separate measurement that this owner is STILL serving this garden.
114
+ // They were the same expression until a Claude session switch inside one pid left a retired
115
+ // garden's marker reading as an armed doorbell and a real message rotted unread in its
116
+ // mailbox. `entwurf_self` calls the same composition, so a citizen's own replyability can
117
+ // never disagree with what dispatch decided about it. ──────────
108
118
  const mailboxDeliverabilityFor = (identity) => {
109
119
  const wakeMode = metaCapabilityFor(identity.backend).wakeMode;
110
- const marker = io.readReceiverMarker(identity.gardenId);
111
- const matched = receiverMarkerMatchesIdentity(marker, identity);
120
+ const { ownerAlive, watchArmed } = resolveMailboxReceiverFacts(identity, {
121
+ readReceiverMarker: io.readReceiverMarker,
122
+ readSenderMarker: io.readSenderMarker,
123
+ });
112
124
  return mailboxConversationalDeliverable({
113
125
  wakeMode,
114
126
  recordBacked: true,
115
- ownerAlive: matched,
116
- watchArmed: matched,
127
+ ownerAlive,
128
+ watchArmed,
117
129
  });
118
130
  };
119
131
  // ── target resolution (QB1 + QB2) ─────────────────────────────────────────
@@ -49,7 +49,12 @@ export async function executeDispatch(decision, deps) {
49
49
  kind: "executed",
50
50
  receipt,
51
51
  transport,
52
- outcome: { transport: "control-socket", outcome: r.outcome, rejectReason: r.rejectReason },
52
+ outcome: {
53
+ transport: "control-socket",
54
+ outcome: r.outcome,
55
+ rejectReason: r.rejectReason,
56
+ messagePath: r.messagePath,
57
+ },
53
58
  };
54
59
  }
55
60
  catch (err) {
@@ -80,7 +85,14 @@ export async function executeDispatch(decision, deps) {
80
85
  if (r.success !== true) {
81
86
  throw new Error("entwurf-v2-runner: meta-mailbox send returned success:false (contract violation; a mailbox has no in-band reject).");
82
87
  }
83
- return { kind: "executed", receipt, transport, outcome: { transport: "meta-mailbox", success: true } };
88
+ return {
89
+ kind: "executed",
90
+ receipt,
91
+ transport,
92
+ // #98 R: carry the enqueue receipt verbatim. `undefined` stays `undefined` —
93
+ // the runner never substitutes a guessed path for a missing one.
94
+ outcome: { transport: "meta-mailbox", success: true, messagePath: r.messagePath },
95
+ };
84
96
  }
85
97
  catch (err) {
86
98
  return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
@@ -58,7 +58,7 @@ export async function executeControlSocketSend(plan, lock, deps) {
58
58
  drive = { outcome: "failed", error: err };
59
59
  }
60
60
  finalizeRelease(policy, deps, held, drive);
61
- return { outcome: drive.outcome, rejectReason: drive.rejectReason };
61
+ return { outcome: drive.outcome, rejectReason: drive.rejectReason, messagePath: drive.messagePath };
62
62
  }
63
63
  /** Drive the 1차 send and route a connect failure through the F3 split. */
64
64
  async function driveSend(plan, lock, deps) {
@@ -122,7 +122,10 @@ async function driveDeadFallback(plan, lock, deps) {
122
122
  // hand never reaches for the mailbox on its own; only the resolver routes here.
123
123
  try {
124
124
  const r = await deps.sendViaMailbox(rePlan, lock);
125
- return { outcome: r.success ? "fallback-sent" : "rejected" };
125
+ // #98 R: this leg writes a `.msg` exactly like the primary mailbox rail, so it
126
+ // owes the sender the same per-message receipt. Carried only on success — a
127
+ // `rejected` enqueue wrote no file to name.
128
+ return r.success ? { outcome: "fallback-sent", messagePath: r.messagePath } : { outcome: "rejected" };
126
129
  }
127
130
  catch (err) {
128
131
  return { outcome: "failed", error: err };
@@ -18,6 +18,7 @@
18
18
  * - `runAndRenderEntwurfV2FromSurface` — assemble production deps + run + render, so the root
19
19
  * surface never needs to name the `EntwurfV2RunResult` union (it only sees `{text,isError}`).
20
20
  */
21
+ import { basename } from "node:path";
21
22
  import { makeProductionEntwurfV2Deps } from "./entwurf-v2-production.js";
22
23
  import { runEntwurfV2 } from "./entwurf-v2-runner.js";
23
24
  import { FRESH_CUT_PRESCRIPTION } from "./meta-session.js";
@@ -102,6 +103,12 @@ export function renderEntwurfV2Result(result) {
102
103
  `\n ${c.detail}` +
103
104
  (c.holder ? `\n holder: pid ${c.holder.pid} on ${c.holder.hostname} since ${c.holder.createdAt}` : "");
104
105
  }
106
+ // #101 갭 C: name WHICH receiver axis failed. "mailbox-undeliverable" alone sent a
107
+ // caller looking for a dead session when the session was alive and had simply
108
+ // switched to another garden — the predicate knew that and the surface threw it away.
109
+ if (result.diagnostic?.kind === "mailbox-undeliverable") {
110
+ text += `\n mailbox-undeliverable: ${result.diagnostic.reason}`;
111
+ }
105
112
  return { text, isError: true };
106
113
  }
107
114
  case "executed": {
@@ -109,8 +116,13 @@ export function renderEntwurfV2Result(result) {
109
116
  if (o.transport === "control-socket") {
110
117
  const delivered = o.outcome === "sent" || o.outcome === "fallback-sent";
111
118
  const reason = o.rejectReason ? ` (reason: ${o.rejectReason})` : "";
119
+ // #98 R, fallback leg: a dead socket that re-resolved to the mailbox wrote a
120
+ // `.msg` — name it, exactly as the primary mailbox rail does. Absent on a
121
+ // socket-to-socket retry (no file) and on every non-mailbox outcome, so the
122
+ // line degrades to the bare outcome rather than printing "undefined".
123
+ const enqueued = o.messagePath ? ` (enqueued ${basename(o.messagePath)})` : "";
112
124
  return {
113
- text: `entwurf_v2 control-socket → ${o.outcome}${reason}`,
125
+ text: `entwurf_v2 control-socket → ${o.outcome}${reason}${enqueued}`,
114
126
  isError: !delivered,
115
127
  };
116
128
  }
@@ -121,8 +133,17 @@ export function renderEntwurfV2Result(result) {
121
133
  isError: false,
122
134
  };
123
135
  }
124
- // meta-mailbox
125
- return { text: "entwurf_v2 meta-mailbox enqueued", isError: false };
136
+ // meta-mailbox. #98 R: name the FILE that was enqueued, so the sender's transcript
137
+ // carries a per-message identifier instead of a bare literal. Only the basename —
138
+ // the directory is `<meta-mailbox>/<target garden id>/`, which the caller already
139
+ // typed. Deliberately NOT a read stamp: at enqueue time `lastReadAt` belongs to the
140
+ // PREVIOUS message, so printing it would claim a read that has not happened.
141
+ // A dep that omits the receipt falls back to the old literal rather than printing
142
+ // "undefined" — the delivery still happened.
143
+ return {
144
+ text: `entwurf_v2 meta-mailbox → enqueued${o.messagePath ? ` (${basename(o.messagePath)})` : ""}`,
145
+ isError: false,
146
+ };
126
147
  }
127
148
  case "execution-failed": {
128
149
  if (result.releaseFailed && result.finalizedOutcome) {
@@ -1661,6 +1661,44 @@ export function readMetaReceiverMarker(opts) {
1661
1661
  return null;
1662
1662
  }
1663
1663
  }
1664
+ /**
1665
+ * Retire a receiver presence marker THIS owner pid wrote (#101 결함 A).
1666
+ *
1667
+ * A native process that switches sessions in place — an in-session `/resume` or `/clear`
1668
+ * fires a second SessionStart inside the same pid under a new native session id — leaves
1669
+ * the previous garden's marker behind, naming a live owner whose watch is gone. Measured
1670
+ * on oracle 2026-09-04: one pid (143742) held two gardens' markers four seconds apart, and
1671
+ * mail sent to the retired one sat unread.
1672
+ *
1673
+ * ONLY THE MARKER. The meta-record stays: records are identity, and deleting one
1674
+ * outside `meta-bridge-fresh-cut` would fight store certification (Hard Rule 7/8).
1675
+ * A retired citizen keeps its record, its transcript and its listing — it loses only
1676
+ * the claim that a doorbell is armed for it, which is exactly the claim that stopped
1677
+ * being true.
1678
+ *
1679
+ * Fail-closed the other way too: the marker is read back first and removed ONLY when its
1680
+ * `ownerPid` field equals the one passed here — pid equality, which is what a caller can
1681
+ * actually check; it does not prove the same PROCESS wrote it (a reused pid compares equal),
1682
+ * and the reader-side start-key guard is where that finer distinction lives. It is enough for
1683
+ * the rule this serves: a hook never retires a watch some other pid is holding. Returns whether a marker was actually removed. Never throws — a retirement
1684
+ * that cannot happen is a log line, not a broken session start.
1685
+ */
1686
+ export function removeMetaReceiverMarker(opts) {
1687
+ try {
1688
+ const receiversDir = opts.receiversDir ?? defaultMetaReceiversDir();
1689
+ const file = metaReceiverMarkerPath(opts.gardenId, receiversDir);
1690
+ // verifyOwner:false — the point is the OWNERSHIP field, not the owner's liveness;
1691
+ // a marker whose owner already died is still this pid's to clean up when it names it.
1692
+ const marker = readMetaReceiverMarker({ markerPath: file, verifyOwner: false });
1693
+ if (!marker || marker.ownerPid !== opts.ownerPid)
1694
+ return false;
1695
+ fs.unlinkSync(file);
1696
+ return true;
1697
+ }
1698
+ catch {
1699
+ return false;
1700
+ }
1701
+ }
1664
1702
  /**
1665
1703
  * Idempotent fs upsert (writes v3 identity). CERTIFY the whole active store, then
1666
1704
  * decide create-vs-attach on record EXISTENCE and write atomically as v3. On
@@ -2077,9 +2115,17 @@ export function enqueueMetaMessage(opts) {
2077
2115
  * Drain a garden citizen's mailbox: read every unread message (a fresh `.msg`
2078
2116
  * read before its doorbell, or a doorbell-rung `.msg.delivered`), archive each to
2079
2117
  * `*.read` so a re-read never double-returns, and — only if at least one message
2080
- * was read — stamp `lastReadAt` (NOT `lastDeliveredAt`: the doorbell owns
2081
- * delivery-time, see the stamp-site note below). An empty inbox mutates nothing:
2082
- * reading nothing is not a receipt.
2118
+ * was read — stamp `lastReadAt`. `lastDeliveredAt` stays untouched (see the
2119
+ * stamp-site note below). An empty inbox mutates nothing: reading nothing is not
2120
+ * a receipt.
2121
+ *
2122
+ * WHERE THE PER-MESSAGE TRUTH LIVES (#98 5a, corrected 2026-09-03). The FILE SUFFIX
2123
+ * is the per-message receipt: `.msg` = enqueued, `.msg.delivered` = the doorbell
2124
+ * rang for it, `.msg.delivered.read` = this function handed it to the reader.
2125
+ * `state.json` is NOT that — its three slots are GARDEN-WIDE and overwritten, so
2126
+ * `lastReadAt` says "this citizen last read something at T", never "message X was
2127
+ * read". A sender that quotes `lastReadAt` back as the fate of the letter it just
2128
+ * queued is quoting the PREVIOUS letter's read. That misreading is what opened #98.
2083
2129
  */
2084
2130
  export function readMetaInbox(opts) {
2085
2131
  const now = opts.now ?? new Date();
@@ -2101,10 +2147,18 @@ export function readMetaInbox(opts) {
2101
2147
  if (messages.length === 0) {
2102
2148
  return { gardenId: citizen.gardenId, messages, readAt: null, recordPath: recordFile };
2103
2149
  }
2104
- // 3D-4 the cut: the read receipt lives SOLELY in the mailbox state store now.
2105
- // Stamp lastReadAt — the one receipt this layer stamps honestly (it KNOWS the body
2106
- // reached the reader). lastDeliveredAt is the doorbell's to own; stamping it here
2107
- // would report read-time as delivery-time, so it is left as the doorbell left it.
2150
+ // 3D-4 the cut: the garden-wide read receipt lives SOLELY in the mailbox state store
2151
+ // now. Stamp lastReadAt — the one slot this layer stamps honestly (it KNOWS a body
2152
+ // reached the reader at this instant). lastDeliveredAt is left alone, and #98 5a
2153
+ // corrects WHY: the old comment said "the doorbell owns it", but the shipped
2154
+ // doorbell.sh (54 lines) writes NOTHING to state.json — measured, zero state writes.
2155
+ // So `lastDeliveredAt` is a RESERVED SLOT nobody stamps, permanently null on all
2156
+ // ~180 on-disk states, while 900+ files carry a `.delivered` suffix. Stamping it
2157
+ // here would still be wrong (it would report read-time as delivery-time), so it
2158
+ // stays null — but do not read "the doorbell will fill it in" into that. The
2159
+ // per-message delivery fact is the `.delivered` SUFFIX. The field is left in place
2160
+ // deliberately: removing it is a migration (the parser rejects unknown keys AND a
2161
+ // bumped schemaVersion, and every stamp re-parses first), tracked as #98 5b.
2108
2162
  // The state stamp returns the updated state, whose lastReadAt IS the D7 read-receipt.
2109
2163
  // Inside the messages.length>0 branch by construction — an empty inbox already
2110
2164
  // early-returned (no .read archive, state untouched), so "read nothing" is no
@@ -28,6 +28,20 @@
28
28
  * a UserPromptSubmit fire does a degraded RECORD backfill (upsert) but cannot
29
29
  * re-arm the idle watch — the record's address is restored, the wake is not.
30
30
  *
31
+ * SESSION SWITCH (#101). One Claude process serves one session at a time, but it can
32
+ * change which. Measured on oracle 2026-09-04 (raw lab S1-S6, Claude Code 2.1.260): a bare
33
+ * `claude` mints a NEW session (`source=startup`), and an in-session `/resume` or `/clear`
34
+ * then fires a SECOND SessionStart under the SAME pid for a DIFFERENT native id
35
+ * (`source=resume` / `source=clear`). The first garden is left behind — in the #101 field
36
+ * case its transcript was never written at all — while its receiver marker still names a
37
+ * LIVE owner, so a sender reads an armed doorbell nobody holds. This hook therefore reads
38
+ * the sender marker BEFORE overwriting it and retires the previous garden's receiver marker
39
+ * (marker only — records are identity and are never deleted here; and only a marker this
40
+ * pid owns). Compaction (`source=compact`) re-fires SessionStart for the SAME native id, so
41
+ * the same rule retires nothing there — measured, not assumed. The envelope's `source` is
42
+ * logged beside all of it and decides nothing: the switch is settled by what is on disk,
43
+ * which holds on every host and vendor version.
44
+ *
31
45
  * LAUNCH: never invoked directly by Claude. `hooks.json` declares the EXEC form
32
46
  * (`command` = `<plugin-root>/scripts/hook-launch.sh`, `args` = [node, this file]),
33
47
  * and the launcher `exec`s that argv — so this process inherits the launcher's pid
@@ -45,7 +59,7 @@
45
59
  */
46
60
  import * as fs from "node:fs";
47
61
  import * as path from "node:path";
48
- import { defaultMetaMailboxDir, defaultMetaSessionsDir, isPlausibleOwnerPid, upsertMetaSession, writeMetaReceiverMarker, writeMetaSenderMarker, } from "./lib/meta-session.js";
62
+ import { defaultMetaMailboxDir, defaultMetaSessionsDir, isPlausibleOwnerPid, readMetaSenderMarker, removeMetaReceiverMarker, upsertMetaSession, writeMetaReceiverMarker, writeMetaSenderMarker, } from "./lib/meta-session.js";
49
63
  function logLine(level, message) {
50
64
  try {
51
65
  // dirname(meta-sessions) == the pi agent dir — no extra resolver export needed.
@@ -168,6 +182,13 @@ function main() {
168
182
  ? env.model_id
169
183
  : undefined;
170
184
  const eventName = typeof env.hook_event_name === "string" ? env.hook_event_name : "SessionStart";
185
+ // `source` (startup | resume | clear | compact) is Claude's own word for WHY this
186
+ // SessionStart fired. It is logged on every line below and decides nothing: a session
187
+ // switch is settled by what is on disk (the sender marker's garden), which is true on
188
+ // every host and every vendor version. Logging it is how this host finally gets a
189
+ // receipt for the envelope order the #101 diagnosis could only read from vendor docs —
190
+ // and the raw lab (scripts/raw-claude-session-switch) reads these lines, not a guess.
191
+ const source = typeof env.source === "string" && env.source.length > 0 ? env.source : "(unset)";
171
192
  if (!sessionId || !transcriptPath) {
172
193
  // A degraded envelope: cannot mint an honest reference record. Log + no-op
173
194
  // rather than write a half-record or guess a transcript path. LEVEL depends
@@ -185,7 +206,7 @@ function main() {
185
206
  input: { backend: "claude-code", nativeSessionId: sessionId, transcriptPath, cwd, model },
186
207
  });
187
208
  gardenId = result.record.gardenId;
188
- logLine("INFO", `${result.action} record ${path.basename(result.path)} (event=${eventName}, native=${sessionId})`);
209
+ logLine("INFO", `${result.action} record ${path.basename(result.path)} (event=${eventName}, source=${source}, native=${sessionId})`);
189
210
  }
190
211
  catch (err) {
191
212
  // Best-effort: a broken record store must surface via the doctor, not by
@@ -210,9 +231,48 @@ function main() {
210
231
  // an env var. Missing launcher provenance or an implausible parent yields no marker.
211
232
  const ownerPid = resolveMetaHookOwnerPid();
212
233
  if (ownerPid !== null) {
234
+ // SESSION SWITCH RETIREMENT (#101 결함 A). One Claude process serves ONE session at a
235
+ // time, but it can switch which: an in-session `/resume` or `/clear` fires a second
236
+ // SessionStart under the same pid for a different native id, leaving the session the
237
+ // process started with behind (measured on oracle, meta-bridge-hook.log 2026-09-04
238
+ // 13:13:04 `source=startup` → 13:13:37 `source=resume`; the field case at 09:31:35 →
239
+ // 09:31:39 is the same shape). Whatever it was serving before is no longer being
240
+ // drained, so the marker advertising its doorbell has to go.
241
+ //
242
+ // The evidence is the sender marker as it stands RIGHT NOW — pid → the garden this
243
+ // process serves — which is why this reads it BEFORE the write below overwrites it with
244
+ // the new garden. No vendor field is consulted: `source` is logged, not branched on, so
245
+ // a host or version that words it differently changes nothing here. A same-garden
246
+ // re-registration (every UserPromptSubmit, a CwdChanged, a re-fired SessionStart) finds
247
+ // an equal garden id and retires NOTHING — the marker it would remove is the live one.
248
+ //
249
+ // Only the marker, never the record (see removeMetaReceiverMarker), and only a marker
250
+ // this pid owns.
251
+ //
252
+ // AND ONLY ON AN EVENT THAT CAN ARM THE REPLACEMENT (cross-review, 2026-09-04). This
253
+ // block sits before the UserPromptSubmit early-return, and UPS cannot emit watchPaths —
254
+ // so a retirement reached from there would take a doorbell down with nothing in the same
255
+ // run able to put one back. A watch is retired only by a run that arms one; that is the
256
+ // whole rule. `armProvenanceFor` is the same predicate the arm block below uses, so the
257
+ // two can never disagree about which events those are.
258
+ //
259
+ // It is NOT a defence against a keystroke from a session this pid has left: measured on
260
+ // oracle 2026-09-04 (raw lab S1-S6, meta-bridge-hook.log), every UserPromptSubmit named
261
+ // the native id its own pid's preceding SessionStart had established — 8 of 8, none
262
+ // otherwise. The hook runs synchronously inside the session's own process, so a UPS
263
+ // envelope is that session speaking, and the sender pointer it moves is authoritative.
264
+ const previous = armProvenanceFor(eventName) !== null
265
+ ? readMetaSenderMarker({ backend: "claude-code", ownerPid, verifyOwner: false })
266
+ : null;
267
+ if (previous && previous.gardenId !== gardenId) {
268
+ const retired = removeMetaReceiverMarker({ gardenId: previous.gardenId, ownerPid });
269
+ logLine("INFO", retired
270
+ ? `retired receiver marker ${previous.gardenId} — owner pid ${ownerPid} switched to ${gardenId} (event=${eventName}, source=${source})`
271
+ : `no receiver marker to retire for ${previous.gardenId} — owner pid ${ownerPid} switched to ${gardenId} (event=${eventName}, source=${source})`);
272
+ }
213
273
  try {
214
274
  writeMetaSenderMarker({ backend: "claude-code", gardenId, nativeSessionId: sessionId, cwd, ownerPid });
215
- logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${eventName})`);
275
+ logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${eventName}, source=${source})`);
216
276
  }
217
277
  catch (err) {
218
278
  logLine("WARN", `sender marker write failed (event=${eventName}, pid=${ownerPid}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`);
@@ -259,7 +319,7 @@ function main() {
259
319
  ownerPid,
260
320
  armProvenance,
261
321
  });
262
- logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName}`);
322
+ logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName} source=${source}`);
263
323
  }
264
324
  catch (err) {
265
325
  logLine("WARN", `receiver marker write failed (event=${eventName}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`);
@@ -66,7 +66,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
66
66
  import { z } from "zod";
67
67
 
68
68
  import { controlSocketPathIn, defaultControlSocketDir } from "../../../pi-extensions/lib/control-socket-path.js";
69
- import { receiverMarkerMatchesIdentity } from "../../../pi-extensions/lib/entwurf-deliverability.ts";
69
+ import { resolveMailboxReceiverFacts } from "../../../pi-extensions/lib/entwurf-deliverability.ts";
70
70
  import { listEntwurfFacts } from "../../../pi-extensions/lib/entwurf-fact-provider.ts";
71
71
  import { renderEntwurfPeers } from "../../../pi-extensions/lib/entwurf-peers-render.ts";
72
72
  import { computeSelfAddressability, type MetaDeliveryDomain } from "../../../pi-extensions/lib/entwurf-self-address.ts";
@@ -91,6 +91,8 @@ import {
91
91
  readActiveStoreEntries,
92
92
  readMetaInbox,
93
93
  readMetaReceiverMarker,
94
+ readMetaSenderMarker,
95
+ requireBackend,
94
96
  } from "../../../pi-extensions/lib/meta-session.ts";
95
97
  import { freshCall, renderFreshCall } from "../../../pi-extensions/lib/mux-fresh-call.ts";
96
98
  import { RESUME_CALL_REJECT_HINT, resumeCall } from "../../../pi-extensions/lib/mux-resume-call.ts";
@@ -272,9 +274,12 @@ async function buildTrustedMetaSenderEnvelope(cwd: string = process.cwd()): Prom
272
274
  // admits another self-fetch citizen.
273
275
  // none ← neither. omp today: no mailbox drain, no native-push adapter. Rendering
274
276
  // this as self-fetch printed a mailboxPath nothing drains.
275
- // self-fetch (claude-code/copilot): can this citizen's own inbox wake? → the receiver
276
- // presence marker (readMetaReceiverMarker folds a dead/reused owner to null, so a match
277
- // means a live, ARMED receiver the sender marker proves identity, never an armed watch).
277
+ // self-fetch (claude-code/copilot): can this citizen's own inbox wake? → the SHARED
278
+ // receiver composition `resolveMailboxReceiverFacts`, the same one the v2 dispatch seam
279
+ // uses, so a citizen's self-reported replyability can never disagree with what dispatch
280
+ // decides about it. It reads the presence marker (a dead/reused owner already folds to
281
+ // null) AND, where the watch owner is the sender-marker process, the #101 join that says
282
+ // the owner is still serving THIS garden rather than one it switched away from.
278
283
  // native-push (antigravity): there is no inbox and no watch. A reply is injected into a
279
284
  // live app-server conversation, so only an adapter probe can answer. Composing the
280
285
  // receiver atom here would demand `watchArmed` from a backend that never arms one, and
@@ -298,14 +303,17 @@ async function buildTrustedMetaSenderEnvelope(cwd: string = process.cwd()): Prom
298
303
  }
299
304
  : metaDeliveryDomain === "self-fetch"
300
305
  ? (() => {
301
- const receiver = readMetaReceiverMarker({ gardenId: identity.gardenId });
302
- const active = receiverMarkerMatchesIdentity(receiver, identity);
306
+ const receiver = resolveMailboxReceiverFacts(identity, {
307
+ readReceiverMarker: (gardenId: string) => readMetaReceiverMarker({ gardenId }),
308
+ readSenderMarker: (backend: string, ownerPid: number) =>
309
+ readMetaSenderMarker({ backend: requireBackend(backend), ownerPid }),
310
+ });
303
311
  return {
304
312
  origin: "meta-session" as const,
305
313
  metaDeliveryDomain,
306
314
  recordBacked: true,
307
- ownerAlive: active,
308
- watchArmed: active,
315
+ ownerAlive: receiver.ownerAlive,
316
+ watchArmed: receiver.watchArmed,
309
317
  };
310
318
  })()
311
319
  : {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for Claude Code, Codex, Antigravity, and pi harnesses.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -143,6 +143,7 @@
143
143
  "check-omp-receive-arm": "./run.sh check-omp-receive-arm",
144
144
  "check-omp-fresh-preflight": "./run.sh check-omp-fresh-preflight",
145
145
  "check-hook-launch-topology": "./run.sh check-hook-launch-topology",
146
+ "check-meta-hook-session-switch": "./run.sh check-meta-hook-session-switch",
146
147
  "check-acp-carrier-augment": "./run.sh check-acp-carrier-augment",
147
148
  "check-agy-permission-matrix": "./run.sh check-agy-permission-matrix",
148
149
  "check-gate-qualification": "./run.sh check-gate-qualification",
@@ -165,7 +166,7 @@
165
166
  "check:toolchain": "pnpm lint && pnpm typecheck",
166
167
  "check:vitest": "./run.sh check-mux-fresh-call && ./run.sh check-acp-sdk-surface",
167
168
  "check:contracts": "./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-harness-admission-parity && ./run.sh check-capability-bundle-reach && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-capability-source && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-copilot-statusline && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-lock && ./run.sh check-entwurf-v2-decider && ./run.sh check-entwurf-v2-matrix && ./run.sh check-entwurf-v2-release && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-send-fallback && ./run.sh check-entwurf-v2-mailbox && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-runner && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-entwurf-resume-args && ./run.sh check-resume-launch-identity && ./run.sh check-mux-placement && ./run.sh check-mux-launch && ./run.sh check-mux-resume-call && ./run.sh check-mux-parent-artifact && ./run.sh check-mux-launcher-fence && ./run.sh check-entwurf-v2-visible-resume && ./run.sh check-entwurf-facts && ./run.sh check-control-socket-path && ./run.sh check-socket-discovery && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-auth-boundary && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-carrier-augment",
168
- "check:hermetic": "./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-hook-launch-topology && ./run.sh check-copilot-birth-hook && ./run.sh check-copilot-receive-arm && ./run.sh check-copilot-launch && ./run.sh check-meta-identity-consumers && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh check-meta-doctor-oracle && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-copilot-statusline-state && ./run.sh smoke-copilot-mcp-state && ./run.sh smoke-omp-bridge-state && ./run.sh smoke-omp-mcp-state && ./run.sh smoke-omp-receive-state && ./run.sh check-omp-receive-arm && ./run.sh check-omp-fresh-preflight && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-bridge-boot && ./run.sh check-probe-bridge-command && ./run.sh check-meta-facts && ./run.sh check-agy-sender-identity && ./run.sh check-acp-provider-surface && ./run.sh check-acp-stop-reason && ./run.sh check-acp-prompt-lifecycle && ./run.sh check-acp-usage-accounting && ./run.sh check-acp-launch-namespace && ./run.sh check-acp-stream-hooks && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-release-gate-outcomes && ./run.sh check-probe-ordering && ./run.sh check-probe-cli-shim && ./run.sh check-acp-cortex",
169
+ "check:hermetic": "./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-hook-launch-topology && ./run.sh check-meta-hook-session-switch && ./run.sh check-copilot-birth-hook && ./run.sh check-copilot-receive-arm && ./run.sh check-copilot-launch && ./run.sh check-meta-identity-consumers && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh check-meta-doctor-oracle && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-copilot-statusline-state && ./run.sh smoke-copilot-mcp-state && ./run.sh smoke-omp-bridge-state && ./run.sh smoke-omp-mcp-state && ./run.sh smoke-omp-receive-state && ./run.sh check-omp-receive-arm && ./run.sh check-omp-fresh-preflight && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-bridge-boot && ./run.sh check-probe-bridge-command && ./run.sh check-meta-facts && ./run.sh check-agy-sender-identity && ./run.sh check-acp-provider-surface && ./run.sh check-acp-stop-reason && ./run.sh check-acp-prompt-lifecycle && ./run.sh check-acp-usage-accounting && ./run.sh check-acp-launch-namespace && ./run.sh check-acp-stream-hooks && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-release-gate-outcomes && ./run.sh check-probe-ordering && ./run.sh check-probe-cli-shim && ./run.sh check-acp-cortex",
169
170
  "check:package": "./run.sh check-install-surface && ./run.sh smoke-meta-install-state && ./run.sh smoke-agy-install-state && ./run.sh smoke-setup-verdict && ./run.sh check-pack-pin-matcher && ./run.sh check-package-source-routing && ./run.sh check-install-preflight && ./run.sh check-pack"
170
171
  },
171
172
  "pi": {
@@ -45,7 +45,9 @@
45
45
  "command": "${CLAUDE_PLUGIN_ROOT}/scripts/hook-launch.sh",
46
46
  "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/doorbell.sh"],
47
47
  "asyncRewake": true,
48
- "timeout": 20
48
+ "timeout": 20,
49
+ "rewakeSummary": "entwurf inbox: sibling mail arrived",
50
+ "rewakeMessage": "entwurf mailbox notice:"
49
51
  }
50
52
  ]
51
53
  }
@@ -6,15 +6,20 @@
6
6
  # with a doorbell notice. Free: a file write + continuation of an already-running
7
7
  # subscription session (no `claude -p` spawn).
8
8
  #
9
- # ADDRESSED by GARDEN ID: the changed path arrives on stdin as `file_path`; its
10
- # directory IS this session's garden mailbox (<meta-mailbox>/<garden-id>/). So
11
- # this hook touches ONLY its own mailbox — a sender that pokes one garden id's
12
- # signal wakes only that session. No node needed here; the dirname is the mailbox.
9
+ # ADDRESSED by GARDEN ID: the changed path arrives on stdin as `file_path` and this
10
+ # hook takes its DIRECTORY as the mailbox to process. No node needed here; the
11
+ # dirname is the mailbox.
13
12
  #
14
- # DOORBELL ONLY: announce "you have mail" + the body path on stderr (the sole
15
- # asyncRewake payload channel stdout is dropped). NEVER push imperatives; strong
16
- # models flag hook-injected commands as prompt injection. The agent self-fetches
17
- # the body with its own trusted tool, and that inbox-read is the real D7 receipt.
13
+ # Two statements, deliberately kept apart (#98, 2026-09-03 they used to be one
14
+ # sentence claiming this hook "touches ONLY its own mailbox"):
15
+ # - PREMISE: under a normal install the only watch this session armed is its own
16
+ # <garden-id>/inbox.signal, so that is the only file_path that arrives.
17
+ # - LIMIT: this script does not verify it. It trusts `file_path` and never compares
18
+ # the dirname against its garden id. Measured — a second FileChanged hook in the
19
+ # same session made this doorbell process the OTHER mailbox and race it to exit 2,
20
+ # which is why a second such hook cannot coexist with this one.
21
+ # Receipt: scripts/raw-async-delivery/README.md, "What the probe session actually
22
+ # touches". Not a live defect: nothing pokes a signal outside the garden mailbox.
18
23
  #
19
24
  # RUNTIME DEPS: bash + python3 (the FileChanged stdin JSON is parsed with python3
20
25
  # below — robust against escaping, unlike sed/grep). The meta-bridge doctor must