@junghanacs/entwurf 0.17.2 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/AGENTS.md +6 -5
  2. package/CHANGELOG.md +299 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.md +9 -5
  5. package/VERIFY.md +3 -3
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +46 -12
  8. package/docs/setup-clean-host.md +35 -3
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +14 -9
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +9 -6
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +11 -3
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +66 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +10 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +8 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +25 -13
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +6 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +38 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +64 -4
  21. package/mcp/entwurf-bridge/src/index.ts +16 -8
  22. package/package.json +10 -9
  23. package/pi-extensions/entwurf-control.ts +12 -2
  24. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  25. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  26. package/pi-extensions/lib/acp/backend.ts +3 -3
  27. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  28. package/pi-extensions/lib/entwurf-deliverability.ts +104 -0
  29. package/pi-extensions/lib/entwurf-fact-provider.ts +20 -6
  30. package/pi-extensions/lib/entwurf-facts.ts +52 -3
  31. package/pi-extensions/lib/entwurf-peer-observe.ts +77 -0
  32. package/pi-extensions/lib/entwurf-peers-render.ts +12 -1
  33. package/pi-extensions/lib/entwurf-v2-decider.ts +26 -8
  34. package/pi-extensions/lib/entwurf-v2-production.ts +32 -12
  35. package/pi-extensions/lib/entwurf-v2-surface.ts +6 -0
  36. package/pi-extensions/lib/meta-session.ts +44 -0
  37. package/pi-extensions/meta-bridge-hook.ts +72 -3
  38. package/run.sh +146 -56
  39. package/scripts/check-acp-usage-accounting.ts +9 -9
  40. package/scripts/check-bridge-delivery.ts +42 -1
  41. package/scripts/check-entwurf-fact-provider.ts +22 -1
  42. package/scripts/check-entwurf-facts.ts +17 -2
  43. package/scripts/check-entwurf-peers-surface.ts +20 -2
  44. package/scripts/check-entwurf-self-address.ts +20 -2
  45. package/scripts/check-entwurf-v2-decider.ts +6 -3
  46. package/scripts/check-entwurf-v2-matrix.ts +10 -5
  47. package/scripts/check-entwurf-v2-production.ts +72 -1
  48. package/scripts/check-gate-qualification.ts +63 -12
  49. package/scripts/check-meta-hook-session-switch.ts +560 -0
  50. package/scripts/check-release-gate-outcomes.ts +160 -3
  51. package/scripts/meta-bridge-doctor.sh +29 -4
  52. package/scripts/mutants/bridge-command-boot.json +3 -3
  53. package/scripts/mutants/gate-qualification.json +28 -0
  54. package/scripts/mutants/meta-hook-session-switch.json +223 -0
  55. package/scripts/mutants/pack-install.json +14 -3
  56. package/scripts/mutants/release-gate.json +22 -0
  57. package/scripts/raw-acp-compaction-measure/README.md +77 -0
  58. package/scripts/raw-acp-compaction-measure/probe.ts +196 -0
  59. package/scripts/raw-claude-session-switch/README.md +170 -0
  60. package/scripts/smoke-acp-cortex-live.ts +22 -2
  61. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  62. package/scripts/smoke-acp-v2-send-live.ts +23 -2
  63. package/scripts/smoke-entwurf-chain-live.ts +85 -13
  64. package/scripts/smoke-entwurf-v2-matrix-live.ts +23 -1
  65. package/scripts/smoke-mux-fresh-call-live.ts +13 -3
@@ -0,0 +1,77 @@
1
+ /**
2
+ * entwurf-peer-observe — the IO half of the two OBSERVED peer facts (#101).
3
+ *
4
+ * `entwurf_peers` used to answer a claude-code citizen with `liveness=unsupported` and
5
+ * nothing else, because the control-socket probe genuinely does not apply to a self-fetch
6
+ * backend. Two same-cwd rows — one a live conversation, one an abandoned startup session
7
+ * whose transcript was never written — were therefore byte-identical in the listing, and
8
+ * a caller picking "the newest record in this cwd" (the natural heuristic, and the one the
9
+ * surface encourages) picked the phantom as often as the real one. Both halves of the
10
+ * distinction were already on disk; nothing read them.
11
+ *
12
+ * FACTS, NOT VERBS. `receiver` and `transcript` say what was found, never what to do
13
+ * about it: no `sendable`, no `deliverable`, no transport. The listing stays a listing —
14
+ * dispatch still computes routing at call time from the same underlying markers.
15
+ *
16
+ * ONE MEASUREMENT, TWO WORDS. `receiver` is derived from the SAME
17
+ * `resolveMailboxReceiverFacts` composition the v2 dispatch seam and `entwurf_self` use.
18
+ * The surfaces are allowed to phrase it differently — a listing wants an enum, a reject
19
+ * wants a sentence — but they may not disagree, so the enum is a projection of that atom
20
+ * rather than a second opinion about the same markers.
21
+ */
22
+
23
+ import * as fs from "node:fs";
24
+ import { resolveMailboxReceiverFacts } from "./entwurf-deliverability.ts";
25
+ import type { PeerObservations, ReceiverObservation, TranscriptObservation } from "./entwurf-facts.ts";
26
+ import {
27
+ type MetaBackend,
28
+ type MetaIdentity,
29
+ metaCapabilityFor,
30
+ metaReceiverMarkerPath,
31
+ readMetaReceiverMarker,
32
+ readMetaSenderMarker,
33
+ requireBackend,
34
+ } from "./meta-session.ts";
35
+
36
+ /**
37
+ * The mailbox receiver axis for one citizen.
38
+ *
39
+ * `n/a` is not a failure: a backend with no mailbox (pi's control socket, antigravity's
40
+ * native push) has no receiver marker to be right or wrong about, and printing `none`
41
+ * there would invent a missing thing. The `inactive` / `none` split is what a reader
42
+ * needs to tell "a watch was armed and is no longer valid" from "no watch was ever
43
+ * armed here", so it is taken from the marker FILE's existence rather than from the
44
+ * reader's null — the reader folds absent, corrupt and dead-owner into one null.
45
+ */
46
+ function observeReceiver(identity: MetaIdentity): ReceiverObservation {
47
+ if (metaCapabilityFor(identity.backend).wakeMode !== "self-fetch") return "n/a";
48
+ const facts = resolveMailboxReceiverFacts(identity, {
49
+ readReceiverMarker: (gardenId: string) => readMetaReceiverMarker({ gardenId }),
50
+ readSenderMarker: (backend: string, ownerPid: number) =>
51
+ readMetaSenderMarker({ backend: requireBackend(backend) as MetaBackend, ownerPid }),
52
+ });
53
+ if (facts.ownerAlive && facts.watchArmed) return "active";
54
+ return fs.existsSync(metaReceiverMarkerPath(identity.gardenId)) ? "inactive" : "none";
55
+ }
56
+
57
+ /**
58
+ * Does the recorded transcript exist? A record is minted at SessionStart, but a harness
59
+ * that writes its transcript lazily (Claude Code writes on the first turn) leaves a
60
+ * citizen with no conversation behind it until someone actually says something. The PATH
61
+ * stays private — only its existence crosses to the listing.
62
+ */
63
+ function observeTranscript(identity: MetaIdentity): TranscriptObservation {
64
+ try {
65
+ // A record with no recorded transcript path has nothing to stat — that is the same
66
+ // observable state as a path that is not there, and neither is an `exists` claim.
67
+ return identity.transcriptPath && fs.existsSync(identity.transcriptPath) ? "exists" : "absent";
68
+ } catch {
69
+ // A transcript we cannot stat is not a transcript we can claim exists.
70
+ return "absent";
71
+ }
72
+ }
73
+
74
+ /** The production observer: both axes, measured for one citizen. */
75
+ export function observePeerFacts(identity: MetaIdentity): PeerObservations {
76
+ return { receiver: observeReceiver(identity), transcript: observeTranscript(identity) };
77
+ }
@@ -39,9 +39,20 @@ export interface EntwurfPeersRender {
39
39
  payload: EntwurfPeersPayload;
40
40
  }
41
41
 
42
+ // `receiver` and `transcript` are OBSERVED facts, not routing (#101): they say what was
43
+ // found on disk for this citizen, and a caller still asks entwurf_v2 what may be done
44
+ // about it. They earn a column because for every claude-code row `liveness` reads
45
+ // `unsupported` — the socket probe does not apply — so two rows in one cwd, a live
46
+ // conversation and an abandoned startup session whose transcript was never written, used to
47
+ // be indistinguishable here. `receiver=active|inactive|none|n/a` is a projection of the
48
+ // same measurement dispatch uses; `transcript=exists|absent` is the record's transcript,
49
+ // existence only, never the path.
42
50
  function renderPeerLine(p: PeerFact): string {
43
51
  const model = p.model ?? "(unknown)";
44
- return `- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} cwd=${p.cwd} model=${model}`;
52
+ return (
53
+ `- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} receiver=${p.receiver} ` +
54
+ `transcript=${p.transcript} cwd=${p.cwd} model=${model}`
55
+ );
45
56
  }
46
57
 
47
58
  function renderDiagnosticLine(d: EntwurfDiagnostic): string {
@@ -128,13 +128,24 @@ export type ExecutionPlan =
128
128
  // claim so 5c's at-most-once re-resolve runs under the same nonce) and null for the
129
129
  // lock-free meta-mailbox path (?7).
130
130
  //
131
- // A reject's optional machine-readable diagnostic. Only `target-locked` carries one:
132
- // the `LockConflict` (holder pid/host/createdAt, lockPath, human detail) the lock
133
- // primitive produced on contention. It rides ALONGSIDE the receipt the receipt
134
- // schema is unchanged; 5d's surface renders it onto the reject. (B3: without this the
135
- // holder evidence was dropped at the decider boundary, so a PID-reuse permanent lock
136
- // could not be observed/cleared — F2-P2 "관측 가능해야 수용".)
137
- export type RejectDiagnostic = { kind: "target-locked"; conflict: LockConflict };
131
+ // A reject's optional machine-readable diagnostic, riding ALONGSIDE the receipt — the
132
+ // receipt schema is unchanged and 5d's surface renders the diagnostic onto the reject.
133
+ // Two kinds, both here for the same reason: the evidence that decided the reject was
134
+ // produced one layer down and would otherwise be dropped at this boundary, leaving the
135
+ // caller a verdict with no way to see or clear its cause (F2-P2 "관측 가능해야 수용").
136
+ //
137
+ // target-locked the `LockConflict` (holder pid/host/createdAt, lockPath, human
138
+ // detail) the lock primitive produced on contention. B3: without
139
+ // it a PID-reuse permanent lock could not be observed/cleared.
140
+ // mailbox-undeliverable WHICH receiver axis failed, in the deliverability predicate's own
141
+ // words — no backing record vs. a dead owner vs. a watch that is no
142
+ // longer armed are three different situations with three different
143
+ // fixes, and the bare `mailbox-undeliverable` reason told a caller
144
+ // none of them (#101 갭 C). The predicate already computes the
145
+ // sentence; this carries it instead of discarding it.
146
+ export type RejectDiagnostic =
147
+ | { kind: "target-locked"; conflict: LockConflict }
148
+ | { kind: "mailbox-undeliverable"; reason: string };
138
149
 
139
150
  export type DispatchDecision =
140
151
  | { kind: "reject"; receipt: RejectReceipt; diagnostic?: RejectDiagnostic }
@@ -327,7 +338,14 @@ export async function decideDispatch(input: DispatchInput, deps: DispatchDecider
327
338
  // fail-closed (SE-2 2d-3). resolveDispatch then routes intent × deliverable.
328
339
  const deliverability = await deps.mailboxDeliverabilityFor(identity);
329
340
  const receipt = resolveDispatch(input.intent, "unsupported", deliverability.deliverable);
330
- if (!receipt.ok) return reject(receipt);
341
+ // The predicate's reason travels with the reject (#101 갭 C). It is attached only when
342
+ // undeliverability is what produced the reject — an intent-shaped refusal on a
343
+ // DELIVERABLE target must not be dressed up as a receiver problem.
344
+ if (!receipt.ok) {
345
+ return deliverability.deliverable
346
+ ? reject(receipt)
347
+ : reject(receipt, { kind: "mailbox-undeliverable", reason: deliverability.reason });
348
+ }
331
349
  // the only allow cell here is fire-and-forget → meta-mailbox send.
332
350
  const plan: ExecutionPlan = {
333
351
  transport: "meta-mailbox",
@@ -39,7 +39,7 @@ import {
39
39
  import {
40
40
  type MailboxDeliverabilityResult,
41
41
  mailboxConversationalDeliverable,
42
- receiverMarkerMatchesIdentity,
42
+ resolveMailboxReceiverFacts,
43
43
  } from "./entwurf-deliverability.ts";
44
44
  import { isOutOfSocketDomainGardenIdConflict } from "./entwurf-facts.ts";
45
45
  import { isLivenessSupported } from "./entwurf-v2-contract.ts";
@@ -75,10 +75,13 @@ import {
75
75
  enqueueMetaMessage,
76
76
  type MetaIdentity,
77
77
  type MetaReceiverMarker,
78
+ type MetaSenderMarker,
78
79
  metaCapabilityFor,
79
80
  metaRecordExistsByGardenId,
80
81
  readAddressableMetaIdentity,
81
82
  readMetaReceiverMarker,
83
+ readMetaSenderMarker,
84
+ requireBackend,
82
85
  } from "./meta-session.ts";
83
86
  import {
84
87
  type NativePushAdapter,
@@ -111,6 +114,10 @@ export interface ProductionEntwurfV2Seams {
111
114
  * SE-2 2d-3 active-receiver source; the factory's `mailboxDeliverabilityFor` closure verifies
112
115
  * its identity match. */
113
116
  readReceiverMarker: (gardenId: string) => MetaReceiverMarker | null;
117
+ /** Read the sender marker keyed to a receiver's owner pid — the "which garden does this
118
+ * process serve NOW?" fact the #101 watch-owner join reads. Null = absent / dead owner /
119
+ * corrupt, which the join treats as a retired watch inside its scope. */
120
+ readSenderMarker: (backend: string, ownerPid: number) => MetaSenderMarker | null;
114
121
  /** Record-side lstat of the EXACT target socket path (no connect) for the pre-probe conflict. */
115
122
  inspectPath: (socketPath: string) => Promise<TargetSocketInspection>;
116
123
  acquireLock: (gid: string, deps: { dir?: string }) => AcquireLockResult;
@@ -183,6 +190,9 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
183
190
  metaRecordExists: s.metaRecordExists ?? metaRecordExistsByGardenId,
184
191
  readIdentity: s.readIdentity ?? readAddressableMetaIdentity,
185
192
  readReceiverMarker: s.readReceiverMarker ?? ((gid: string) => readMetaReceiverMarker({ gardenId: gid })),
193
+ readSenderMarker:
194
+ s.readSenderMarker ??
195
+ ((backend: string, ownerPid: number) => readMetaSenderMarker({ backend: requireBackend(backend), ownerPid })),
186
196
  inspectPath: s.inspectPath ?? inspectControlSocketPath,
187
197
  acquireLock: s.acquireLock ?? realAcquireLock,
188
198
  releaseLock: s.releaseLock ?? realReleaseLock,
@@ -207,22 +217,32 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
207
217
  });
208
218
 
209
219
  // ── ONE deliverability seam (SE-2 2d-3): wake-mode capability AND a live active-
210
- // receiver (a presence marker that matches THIS identity). The SAME closure is injected
211
- // into the decider AND the dead-fallback, so a direct send and a re-resolved fallback
212
- // send can never drift to different deliverability verdicts. recordBacked is true by
213
- // construction resolveTarget already proved the record exists before any unsupported-
214
- // backend mailbox route, and the closure is only consulted on that route. A null /
215
- // dead-owner / identity-mismatched marker is fail-closed to inactive (SE-2): a reply to a
216
- // terminated self-fetch citizen is rejected, not enqueued as mailbox garbage. ──────────
220
+ // receiver. The SAME closure is injected into the decider AND the dead-fallback, so a
221
+ // direct send and a re-resolved fallback send can never drift to different deliverability
222
+ // verdicts. recordBacked is true by construction resolveTarget already proved the record
223
+ // exists before any unsupported-backend mailbox route, and the closure is only consulted on
224
+ // that route. A null / dead-owner / identity-mismatched marker is fail-closed to inactive
225
+ // (SE-2): a reply to a terminated self-fetch citizen is rejected, not enqueued as mailbox
226
+ // garbage.
227
+ //
228
+ // The two receiver facts come from the SHARED composition (#101 결함 B), never from one
229
+ // value copied into both slots. `ownerAlive` is the marker↔identity match on a live owner;
230
+ // `watchArmed` is the separate measurement that this owner is STILL serving this garden.
231
+ // They were the same expression until a Claude session switch inside one pid left a retired
232
+ // garden's marker reading as an armed doorbell and a real message rotted unread in its
233
+ // mailbox. `entwurf_self` calls the same composition, so a citizen's own replyability can
234
+ // never disagree with what dispatch decided about it. ──────────
217
235
  const mailboxDeliverabilityFor = (identity: MetaIdentity): MailboxDeliverabilityResult => {
218
236
  const wakeMode = metaCapabilityFor(identity.backend).wakeMode;
219
- const marker = io.readReceiverMarker(identity.gardenId);
220
- const matched = receiverMarkerMatchesIdentity(marker, identity);
237
+ const { ownerAlive, watchArmed } = resolveMailboxReceiverFacts(identity, {
238
+ readReceiverMarker: io.readReceiverMarker,
239
+ readSenderMarker: io.readSenderMarker,
240
+ });
221
241
  return mailboxConversationalDeliverable({
222
242
  wakeMode,
223
243
  recordBacked: true,
224
- ownerAlive: matched,
225
- watchArmed: matched,
244
+ ownerAlive,
245
+ watchArmed,
226
246
  });
227
247
  };
228
248
 
@@ -138,6 +138,12 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
138
138
  `\n ${c.detail}` +
139
139
  (c.holder ? `\n holder: pid ${c.holder.pid} on ${c.holder.hostname} since ${c.holder.createdAt}` : "");
140
140
  }
141
+ // #101 갭 C: name WHICH receiver axis failed. "mailbox-undeliverable" alone sent a
142
+ // caller looking for a dead session when the session was alive and had simply
143
+ // switched to another garden — the predicate knew that and the surface threw it away.
144
+ if (result.diagnostic?.kind === "mailbox-undeliverable") {
145
+ text += `\n mailbox-undeliverable: ${result.diagnostic.reason}`;
146
+ }
141
147
  return { text, isError: true };
142
148
  }
143
149
  case "executed": {
@@ -2017,6 +2017,50 @@ export function readMetaReceiverMarker(opts: ReadMetaReceiverMarkerOptions): Met
2017
2017
  }
2018
2018
  }
2019
2019
 
2020
+ export interface RemoveMetaReceiverMarkerOptions {
2021
+ gardenId: string;
2022
+ /** Only remove a marker this pid owns — a retirement must never reach another process's watch. */
2023
+ ownerPid: number;
2024
+ receiversDir?: string;
2025
+ }
2026
+
2027
+ /**
2028
+ * Retire a receiver presence marker THIS owner pid wrote (#101 결함 A).
2029
+ *
2030
+ * A native process that switches sessions in place — an in-session `/resume` or `/clear`
2031
+ * fires a second SessionStart inside the same pid under a new native session id — leaves
2032
+ * the previous garden's marker behind, naming a live owner whose watch is gone. Measured
2033
+ * on oracle 2026-09-04: one pid (143742) held two gardens' markers four seconds apart, and
2034
+ * mail sent to the retired one sat unread.
2035
+ *
2036
+ * ONLY THE MARKER. The meta-record stays: records are identity, and deleting one
2037
+ * outside `meta-bridge-fresh-cut` would fight store certification (Hard Rule 7/8).
2038
+ * A retired citizen keeps its record, its transcript and its listing — it loses only
2039
+ * the claim that a doorbell is armed for it, which is exactly the claim that stopped
2040
+ * being true.
2041
+ *
2042
+ * Fail-closed the other way too: the marker is read back first and removed ONLY when its
2043
+ * `ownerPid` field equals the one passed here — pid equality, which is what a caller can
2044
+ * actually check; it does not prove the same PROCESS wrote it (a reused pid compares equal),
2045
+ * and the reader-side start-key guard is where that finer distinction lives. It is enough for
2046
+ * 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
2047
+ * that cannot happen is a log line, not a broken session start.
2048
+ */
2049
+ export function removeMetaReceiverMarker(opts: RemoveMetaReceiverMarkerOptions): boolean {
2050
+ try {
2051
+ const receiversDir = opts.receiversDir ?? defaultMetaReceiversDir();
2052
+ const file = metaReceiverMarkerPath(opts.gardenId, receiversDir);
2053
+ // verifyOwner:false — the point is the OWNERSHIP field, not the owner's liveness;
2054
+ // a marker whose owner already died is still this pid's to clean up when it names it.
2055
+ const marker = readMetaReceiverMarker({ markerPath: file, verifyOwner: false });
2056
+ if (!marker || marker.ownerPid !== opts.ownerPid) return false;
2057
+ fs.unlinkSync(file);
2058
+ return true;
2059
+ } catch {
2060
+ return false;
2061
+ }
2062
+ }
2063
+
2020
2064
  export interface UpsertMetaSessionOptions {
2021
2065
  input: MetaIdentityMintInput;
2022
2066
  /** Override the store directory (defaults to {@link defaultMetaSessionsDir}). */
@@ -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
@@ -51,6 +65,8 @@ import {
51
65
  defaultMetaSessionsDir,
52
66
  isPlausibleOwnerPid,
53
67
  type MetaReceiverArmProvenance,
68
+ readMetaSenderMarker,
69
+ removeMetaReceiverMarker,
54
70
  upsertMetaSession,
55
71
  writeMetaReceiverMarker,
56
72
  writeMetaSenderMarker,
@@ -191,6 +207,13 @@ function main(): void {
191
207
  ? env.model_id
192
208
  : undefined;
193
209
  const eventName = typeof env.hook_event_name === "string" ? env.hook_event_name : "SessionStart";
210
+ // `source` (startup | resume | clear | compact) is Claude's own word for WHY this
211
+ // SessionStart fired. It is logged on every line below and decides nothing: a session
212
+ // switch is settled by what is on disk (the sender marker's garden), which is true on
213
+ // every host and every vendor version. Logging it is how this host finally gets a
214
+ // receipt for the envelope order the #101 diagnosis could only read from vendor docs —
215
+ // and the raw lab (scripts/raw-claude-session-switch) reads these lines, not a guess.
216
+ const source = typeof env.source === "string" && env.source.length > 0 ? env.source : "(unset)";
194
217
 
195
218
  if (!sessionId || !transcriptPath) {
196
219
  // A degraded envelope: cannot mint an honest reference record. Log + no-op
@@ -213,7 +236,10 @@ function main(): void {
213
236
  input: { backend: "claude-code", nativeSessionId: sessionId, transcriptPath, cwd, model },
214
237
  });
215
238
  gardenId = result.record.gardenId;
216
- logLine("INFO", `${result.action} record ${path.basename(result.path)} (event=${eventName}, native=${sessionId})`);
239
+ logLine(
240
+ "INFO",
241
+ `${result.action} record ${path.basename(result.path)} (event=${eventName}, source=${source}, native=${sessionId})`,
242
+ );
217
243
  } catch (err) {
218
244
  // Best-effort: a broken record store must surface via the doctor, not by
219
245
  // breaking the user's session open. Log and continue with no arm. This is
@@ -241,9 +267,52 @@ function main(): void {
241
267
  // an env var. Missing launcher provenance or an implausible parent yields no marker.
242
268
  const ownerPid = resolveMetaHookOwnerPid();
243
269
  if (ownerPid !== null) {
270
+ // SESSION SWITCH RETIREMENT (#101 결함 A). One Claude process serves ONE session at a
271
+ // time, but it can switch which: an in-session `/resume` or `/clear` fires a second
272
+ // SessionStart under the same pid for a different native id, leaving the session the
273
+ // process started with behind (measured on oracle, meta-bridge-hook.log 2026-09-04
274
+ // 13:13:04 `source=startup` → 13:13:37 `source=resume`; the field case at 09:31:35 →
275
+ // 09:31:39 is the same shape). Whatever it was serving before is no longer being
276
+ // drained, so the marker advertising its doorbell has to go.
277
+ //
278
+ // The evidence is the sender marker as it stands RIGHT NOW — pid → the garden this
279
+ // process serves — which is why this reads it BEFORE the write below overwrites it with
280
+ // the new garden. No vendor field is consulted: `source` is logged, not branched on, so
281
+ // a host or version that words it differently changes nothing here. A same-garden
282
+ // re-registration (every UserPromptSubmit, a CwdChanged, a re-fired SessionStart) finds
283
+ // an equal garden id and retires NOTHING — the marker it would remove is the live one.
284
+ //
285
+ // Only the marker, never the record (see removeMetaReceiverMarker), and only a marker
286
+ // this pid owns.
287
+ //
288
+ // AND ONLY ON AN EVENT THAT CAN ARM THE REPLACEMENT (cross-review, 2026-09-04). This
289
+ // block sits before the UserPromptSubmit early-return, and UPS cannot emit watchPaths —
290
+ // so a retirement reached from there would take a doorbell down with nothing in the same
291
+ // run able to put one back. A watch is retired only by a run that arms one; that is the
292
+ // whole rule. `armProvenanceFor` is the same predicate the arm block below uses, so the
293
+ // two can never disagree about which events those are.
294
+ //
295
+ // It is NOT a defence against a keystroke from a session this pid has left: measured on
296
+ // oracle 2026-09-04 (raw lab S1-S6, meta-bridge-hook.log), every UserPromptSubmit named
297
+ // the native id its own pid's preceding SessionStart had established — 8 of 8, none
298
+ // otherwise. The hook runs synchronously inside the session's own process, so a UPS
299
+ // envelope is that session speaking, and the sender pointer it moves is authoritative.
300
+ const previous =
301
+ armProvenanceFor(eventName) !== null
302
+ ? readMetaSenderMarker({ backend: "claude-code", ownerPid, verifyOwner: false })
303
+ : null;
304
+ if (previous && previous.gardenId !== gardenId) {
305
+ const retired = removeMetaReceiverMarker({ gardenId: previous.gardenId, ownerPid });
306
+ logLine(
307
+ "INFO",
308
+ retired
309
+ ? `retired receiver marker ${previous.gardenId} — owner pid ${ownerPid} switched to ${gardenId} (event=${eventName}, source=${source})`
310
+ : `no receiver marker to retire for ${previous.gardenId} — owner pid ${ownerPid} switched to ${gardenId} (event=${eventName}, source=${source})`,
311
+ );
312
+ }
244
313
  try {
245
314
  writeMetaSenderMarker({ backend: "claude-code", gardenId, nativeSessionId: sessionId, cwd, ownerPid });
246
- logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${eventName})`);
315
+ logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${eventName}, source=${source})`);
247
316
  } catch (err) {
248
317
  logLine(
249
318
  "WARN",
@@ -294,7 +363,7 @@ function main(): void {
294
363
  ownerPid,
295
364
  armProvenance,
296
365
  });
297
- logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName}`);
366
+ logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName} source=${source}`);
298
367
  } catch (err) {
299
368
  logLine(
300
369
  "WARN",