@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
+ # raw-acp-compaction-measure — what a compacting turn looks like after claude-agent-acp 0.75.0
2
+
3
+ Measurement receipts for the ONE change in the `claude-agent-acp 0.73.0 → 0.75.1` bump that
4
+ reaches entwurf. Not a gate; nothing here runs in any check tier.
5
+
6
+ ```
7
+ LIVE=1 node --experimental-strip-types scripts/raw-acp-compaction-measure/probe.ts
8
+ ```
9
+
10
+ ## The change
11
+
12
+ `0.75.0` (#991, `f74a517`, "surface context compaction as an ACP tool lifecycle") stopped
13
+ sending compaction as assistant text and started sending it as a **synthetic tool call**.
14
+
15
+ `[읽음, ~/repos/3rd/claude-agent-acp]` at `v0.73.0 src/acp-agent.ts:3437-3457` a compaction
16
+ result produced two `agent_message_chunk` texts — `"\n\nCompacting completed."` and
17
+ `"Compacting failed<reason>"`. At `v0.75.1` those are gone; `ContextCompactionLifecycle`
18
+ (`git grep -c` in `src/`: **0** at v0.73.0 and v0.74.0, **2** at v0.75.0 and v0.75.1) drives
19
+ `tool_call` / `tool_call_update` notifications instead
20
+ (`dist/acp-agent.js:31`, `:1877`, `:2711-2742`).
21
+
22
+ **What did NOT change:** the post-compaction occupancy refresh. `v0.73.0
23
+ src/acp-agent.ts:3460+` already emitted a `usage_update` at `compact_boundary`, and 0.75.1
24
+ still does (`dist/acp-agent.js:2740-2752`) — only its source moved, from a
25
+ `getContextUsage` control request to `compact_metadata.post_tokens`. So the
26
+ `used_end`-shrinks-mid-turn case that `backend.ts:1286-1288` already names as #96's weak
27
+ floor is **not newly triggered by this bump**.
28
+
29
+ ## M1 — one live `/compact` turn, and the same notifications replayed through our mapper
30
+
31
+ `[측정 2026-09-06, oracle, claude-agent-acp 0.75.1, model claude-sonnet-5]`
32
+ `compact stopReason=end_turn`, 2 tool-lifecycle notifications. Verbatim from the probe:
33
+
34
+ ```
35
+ tool_call kind=think status=in_progress title="Compact conversation"
36
+ meta={"contextCompaction":{"version":1},"claudeCode":{"toolName":"compact"}}
37
+ tool_call_update kind=- status=failed title=null
38
+ meta={"contextCompaction":{"version":1,"error":"Not enough messages to compact."},
39
+ "claudeCode":{"toolName":"compact"}}
40
+ ```
41
+
42
+ Those exact objects, replayed through the PRODUCTION `applyAcpSessionUpdate`
43
+ (`pi-extensions/lib/acp/event-mapper.ts`) — the same function `backend.ts` calls — produce:
44
+
45
+ ```json
46
+ [{"type":"text","text":"\n[tool:start] Compact conversation\n"},
47
+ {"type":"text","text":"\n[tool:failed] Compact conversation — Compaction failed: Not enough messages to compact.\n"}]
48
+ ```
49
+
50
+ ### What this establishes
51
+
52
+ 1. **No type or contract break.** `renderToolUpdate` (`event-mapper.ts:236-270`) routes every
53
+ `tool_call`/`tool_call_update` regardless of `kind`, so a `kind: "think"` synthetic call
54
+ needs no new branch. The turn ends `end_turn`, not an error.
55
+ 2. **`_meta.contextCompaction` reaches nothing.** It is carried on the notification and
56
+ dropped by the mapper, which reads only `toolCallId` / `title` / `status` / `content` /
57
+ `rawOutput`. No accounting path sees it — `usage.acp`, `_meta.quota` and the four pi
58
+ fields are untouched by this lifecycle.
59
+ 3. **What an operator now SEES changed.** Where 0.73.0 put `Compacting completed.` in the
60
+ assistant's own text, 0.75.1 puts a `[tool:start]` / `[tool:failed]` notice pair in the
61
+ transcript — that pair is what was OBSERVED. On a compaction that succeeds the second
62
+ notice is `[tool:done]`, which is inferred from the mapper's own status branch
63
+ (`event-mapper.ts:262-266`) and not observed here; see the limits below. Either way,
64
+ that notice pair is the whole reachable delta of this bump.
65
+
66
+ ### What this does NOT establish (measured limits, stated rather than rounded off)
67
+
68
+ - **The `completed` branch was not observed.** The seeded session was too short, so the
69
+ vendor answered `"Not enough messages to compact."` and the lifecycle terminated at
70
+ `status: "failed"`. The success path — `status: "completed"` plus
71
+ `_meta.contextCompaction` carrying `trigger` / `preTokens` / `postTokens` / `durationMs`
72
+ (`f74a517` commit message; `contextCompactionMetadataFromBoundary`) — is **inferred from
73
+ source, not measured here.** In the mapper it is the same `status`-transition branch that
74
+ produced `[tool:failed]` above, one literal apart (`[tool:done]`,
75
+ `event-mapper.ts:262-266`).
76
+ - **No organic (auto-trigger) compaction was driven.** Only the explicit `/compact` command.
77
+ - One host, one model, one run.
@@ -0,0 +1,196 @@
1
+ // RAW MEASUREMENT PROBE — not a gate, not in any check tier.
2
+ //
3
+ // LIVE=1 node --experimental-strip-types scripts/raw-acp-compaction-measure/probe.ts
4
+ //
5
+ // WHY THIS EXISTS. claude-agent-acp 0.75.0 (#991, `f74a517`) stopped surfacing Claude's
6
+ // context compaction as assistant text and started surfacing it as a SYNTHETIC ACP tool
7
+ // lifecycle: a `tool_call` with `kind: "think"`, title `Compact conversation`, and
8
+ // `_meta.contextCompaction` schema v1. Reading the dist says that much
9
+ // (`dist/acp-agent.js:31`, `:1877`, `:2711-2742`); it does not say what an entwurf
10
+ // operator actually SEES, because that depends on our own `applyAcpSessionUpdate`.
11
+ //
12
+ // So this probe drives one real `/compact` turn on the pinned adapter, captures the
13
+ // tool-lifecycle notifications verbatim, and then replays those exact notifications
14
+ // through the PRODUCTION event mapper — the same function backend.ts calls — and prints
15
+ // the pi-side transcript fragment they produce. The point is the join: vendor wire on one
16
+ // side, our rendered notice on the other, in one receipt.
17
+ //
18
+ // It asserts nothing and blocks nothing. The receipt goes in README.md next to it.
19
+
20
+ import { type ChildProcessByStdio, spawn } from "node:child_process";
21
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
22
+ import { createRequire } from "node:module";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { Readable, Writable } from "node:stream";
26
+ import { ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
27
+ import { connectAcpClient } from "../../pi-extensions/lib/acp/acp-client.ts";
28
+ import { applyAcpSessionUpdate, createAcpStreamState } from "../../pi-extensions/lib/acp/event-mapper.ts";
29
+ import { terminateChild } from "../lib/acp-child-cleanup.ts";
30
+
31
+ const MODEL_ID = process.env.ENTWURF_ACP_RAW_TURN_MODEL ?? "claude-sonnet-5";
32
+ const OUT = process.env.RAW_COMPACTION_OUT ?? join(tmpdir(), "raw-acp-compaction.json");
33
+
34
+ if (process.env.LIVE !== "1") {
35
+ console.error("[raw-acp-compaction] set LIVE=1 to spend a real turn.");
36
+ process.exit(0);
37
+ }
38
+
39
+ function withTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
40
+ let timer: ReturnType<typeof setTimeout> | undefined;
41
+ const timeout = new Promise<never>((_, reject) => {
42
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
43
+ });
44
+ return Promise.race([p, timeout]).finally(() => {
45
+ if (timer) clearTimeout(timer);
46
+ });
47
+ }
48
+
49
+ const require = createRequire(import.meta.url);
50
+ const bin = require.resolve("@agentclientprotocol/claude-agent-acp/dist/index.js");
51
+
52
+ const scratch = await mkdtemp(join(tmpdir(), "raw-acp-compaction-"));
53
+ const child: ChildProcessByStdio<Writable, Readable, Readable> = spawn(process.execPath, [bin], {
54
+ cwd: scratch,
55
+ env: { ...process.env },
56
+ stdio: ["pipe", "pipe", "pipe"],
57
+ }) as ChildProcessByStdio<Writable, Readable, Readable>;
58
+
59
+ const stderrTail: string[] = [];
60
+ child.stderr.on("data", (c) => {
61
+ stderrTail.push(c.toString());
62
+ if (stderrTail.length > 200) stderrTail.shift();
63
+ });
64
+
65
+ const stream = ndJsonStream(
66
+ Writable.toWeb(child.stdin) as unknown as WritableStream<Uint8Array>,
67
+ Readable.toWeb(child.stdout) as unknown as ReadableStream<Uint8Array>,
68
+ );
69
+
70
+ // Every tool-lifecycle notification, verbatim, in arrival order.
71
+ const toolUpdates: Record<string, unknown>[] = [];
72
+ let agentText = "";
73
+
74
+ const connection = connectAcpClient(
75
+ stream as never,
76
+ {
77
+ sessionUpdate: async (notification: { update?: Record<string, unknown> }) => {
78
+ const u = notification?.update;
79
+ if (!u) return;
80
+ if (u.sessionUpdate === "tool_call" || u.sessionUpdate === "tool_call_update") {
81
+ toolUpdates.push(structuredClone(u));
82
+ }
83
+ if (u.sessionUpdate === "agent_message_chunk") {
84
+ const t = (u.content as { text?: unknown } | undefined)?.text;
85
+ if (typeof t === "string") agentText += t;
86
+ }
87
+ },
88
+ requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
89
+ readTextFile: async () => {
90
+ throw new Error("unexpected readTextFile");
91
+ },
92
+ writeTextFile: async () => {
93
+ throw new Error("unexpected writeTextFile");
94
+ },
95
+ } as never,
96
+ );
97
+
98
+ let failure: Error | null = null;
99
+ try {
100
+ await withTimeout(
101
+ "initialize",
102
+ connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} } as never),
103
+ 30_000,
104
+ );
105
+ const session = (await withTimeout(
106
+ "newSession",
107
+ connection.newSession({ cwd: scratch, mcpServers: [] } as never),
108
+ 60_000,
109
+ )) as { sessionId?: string };
110
+ const sessionId = session?.sessionId;
111
+ if (!sessionId) throw new Error("newSession returned no sessionId");
112
+
113
+ const setConfig = (connection as unknown as { setSessionConfigOption?: unknown }).setSessionConfigOption;
114
+ if (typeof setConfig === "function") {
115
+ await withTimeout(
116
+ "setSessionConfigOption",
117
+ (setConfig as (a: unknown) => Promise<unknown>).call(connection, {
118
+ sessionId,
119
+ configId: "model",
120
+ value: MODEL_ID,
121
+ }),
122
+ 30_000,
123
+ );
124
+ }
125
+
126
+ // One tiny turn so the transcript has something to compact.
127
+ await withTimeout(
128
+ "seed prompt",
129
+ connection.prompt({
130
+ sessionId,
131
+ prompt: [{ type: "text", text: "Reply with exactly SEED and nothing else." }],
132
+ } as never),
133
+ 180_000,
134
+ );
135
+ const seedText = agentText;
136
+ agentText = "";
137
+ const beforeCompact = toolUpdates.length;
138
+
139
+ // The compaction turn. `/compact` is the vendor's own trigger for the lifecycle
140
+ // under measurement (`dist/acp-agent.js:2054`, `:2103`).
141
+ const compactResult = (await withTimeout(
142
+ "compact prompt",
143
+ connection.prompt({ sessionId, prompt: [{ type: "text", text: "/compact" }] } as never),
144
+ 300_000,
145
+ )) as { stopReason?: string };
146
+
147
+ const compactionUpdates = toolUpdates.slice(beforeCompact);
148
+
149
+ // Replay the captured notifications through the PRODUCTION mapper.
150
+ const notices: string[] = [];
151
+ const state = createAcpStreamState(
152
+ { push: (ev: unknown) => notices.push(JSON.stringify(ev)) } as never,
153
+ { api: "entwurf", provider: "entwurf", model: MODEL_ID } as never,
154
+ { timestamp: 0 },
155
+ );
156
+ for (const u of compactionUpdates) applyAcpSessionUpdate(state, u);
157
+
158
+ const receipt = {
159
+ measuredAt: new Date().toISOString(),
160
+ adapter: JSON.parse(
161
+ (await import("node:fs")).readFileSync(
162
+ require.resolve("@agentclientprotocol/claude-agent-acp/package.json"),
163
+ "utf8",
164
+ ),
165
+ ).version,
166
+ model: MODEL_ID,
167
+ seedText: seedText.trim().slice(0, 80),
168
+ compactStopReason: compactResult?.stopReason,
169
+ compactionToolUpdates: compactionUpdates,
170
+ mappedContent: state.output.content,
171
+ mappedStreamEvents: notices,
172
+ };
173
+ await writeFile(OUT, `${JSON.stringify(receipt, null, "\t")}\n`, "utf8");
174
+ console.log(`[raw-acp-compaction] receipt -> ${OUT}`);
175
+ console.log(` adapter: ${receipt.adapter}`);
176
+ console.log(` compact stopReason: ${receipt.compactStopReason}`);
177
+ console.log(` tool updates: ${compactionUpdates.length}`);
178
+ for (const u of compactionUpdates) {
179
+ console.log(
180
+ ` ${String(u.sessionUpdate)} kind=${String(u.kind ?? "-")} status=${String(u.status ?? "-")} title=${JSON.stringify(u.title ?? null)} meta=${JSON.stringify(u._meta ?? null)}`,
181
+ );
182
+ }
183
+ console.log(` pi-side content blocks: ${JSON.stringify(state.output.content)}`);
184
+ } catch (err) {
185
+ failure = err instanceof Error ? err : new Error(String(err));
186
+ console.error(`[raw-acp-compaction] stderr tail:\n${stderrTail.slice(-20).join("")}`);
187
+ } finally {
188
+ connection.close?.();
189
+ await terminateChild(child);
190
+ await rm(scratch, { recursive: true, force: true }).catch(() => {});
191
+ }
192
+
193
+ if (failure) {
194
+ console.error(`[raw-acp-compaction] FAILED: ${failure.message}`);
195
+ process.exit(1);
196
+ }
@@ -0,0 +1,170 @@
1
+ # Raw measurement — what Claude Code's SessionStart actually says on a session switch (#101)
2
+
3
+ **Status: RUN on oracle, 2026-09-04, Claude Code 2.1.260, `--model haiku`.** GLG approved the
4
+ LIVE cells (S1–S5 first, S6 added after). Six cells, one host, one vendor version — that is the
5
+ whole claim. Everything below is verbatim from `~/.pi/agent/meta-bridge-hook.log`, produced by
6
+ the repaired hook, which now logs the envelope's `source` on every line it writes.
7
+
8
+ The #101 repair does **not** depend on any of it: the retirement rule reads the sender marker on
9
+ disk and branches on no vendor field. What this lab adds is the envelope order the diagnosis had
10
+ to inherit from vendor documentation — and it **refuted one inherited sentence** (see S2/S3).
11
+
12
+ ## What the lab changed in the story
13
+
14
+ The diagnosis said the two SessionStarts come from the TUI resume picker firing once for a
15
+ placeholder id and again for the picked id. **Measured, that is not what happens.**
16
+ `claude --resume`, with or without the picker, fires exactly ONE SessionStart carrying the real
17
+ id (S2, S3). The two-SessionStart shape comes from a bare `claude` — which mints a real, empty
18
+ new session — followed by an in-session `/resume` or `/clear` (S4, S5). The field case fits: `ps`
19
+ showed pid 143742 started bare `claude` at 09:31:32, and the second SessionStart landed 4s later.
20
+
21
+ The consequence for the repair is nil (either way one pid stops serving one garden and starts
22
+ serving another, and the sender marker is what says so), but the prose was wrong and is now
23
+ corrected in `meta-bridge-hook.ts`, `entwurf-deliverability.ts`, `meta-session.ts` and
24
+ `check-meta-hook-session-switch.ts`.
25
+
26
+ ## The three falsification conditions, judged
27
+
28
+ The pre-run README named three findings that would have changed the repair. All three are
29
+ answered:
30
+
31
+ 1. **"A switch leaves the sender marker pointing at the OLD garden when the receiver marker has
32
+ already moved"** — the repair's one real assumption. **Not observed.** In S4 and S5 the
33
+ retirement line is written BEFORE the sender marker is rewritten, and both precede the new
34
+ garden's arm (log order below, same millisecond). The hook reads the pre-switch value it needs.
35
+ 2. **"Compaction re-fires SessionStart under a NEW native id, so a compacted session is a garden
36
+ switch"** — **refuted.** Compaction (manual S6a and automatic S6b) re-fires SessionStart for
37
+ the SAME native id, attaches the SAME garden, and retires nothing. The same-garden rule covers
38
+ it; nothing had to change.
39
+ 3. **"Three SessionStarts in one process"** — **not observed** in any cell. Every switch was
40
+ exactly one additional SessionStart, and the two-drive gate fixture stays adequate.
41
+
42
+ ## The cells
43
+
44
+ | cell | how it was reached | SessionStarts | `source` | native id | retirement |
45
+ |---|---|---|---|---|---|
46
+ | S1 | bare `claude` | 1 | `startup` | new | — (nothing prior) |
47
+ | S2 | `claude --resume` → picker → Enter | **1** | `resume` | the picked one | — (no prior garden in this pid) |
48
+ | S3 | `claude --resume <id>` (argv) | **1** | `resume` | the given one | — |
49
+ | S4 | bare `claude`, then in-session `/resume` | **2** | `startup` → `resume` | new → picked | **yes** — the startup garden retired |
50
+ | S5 | in-session `/clear` | 1 more | `clear` | new | **yes** — the previous garden retired |
51
+ | S6a | in-session `/compact` | 1 more | `compact` | **same** | no (same garden) |
52
+ | S6b | automatic compaction at ~98% context | 1 more | `compact` | **same** | no (same garden) |
53
+
54
+ `UserPromptSubmit` carries **no** `source` at all (`source=(unset)`) — worth knowing, since it is
55
+ the event that rewrites the sender marker on every keystroke and must retire nothing.
56
+
57
+ It also always names the session it belongs to. Across the four lab pids, **8 of 8**
58
+ `UserPromptSubmit` events carried the native id their own pid's preceding `SessionStart` had
59
+ established, and none carried any other. The hook runs synchronously inside the session's own
60
+ process, so a keystroke envelope is that session speaking — which is why the pointer it moves is
61
+ treated as authoritative rather than as something to defend against.
62
+
63
+ ## Verbatim receipts
64
+
65
+ **S1 — bare `claude`.** One SessionStart, a fresh garden, `source=startup`. Then one turn, whose
66
+ `UserPromptSubmit` carries no source and retires nothing (same garden):
67
+
68
+ ```
69
+ 2026-09-04T04:10:22.678Z INFO create record 20260904T131022-ac38b6.meta.json (event=SessionStart, source=startup, native=bbba9cbe-f11c-4b67-a0bd-991c3f678238)
70
+ 2026-09-04T04:10:22.681Z INFO sender marker 564537 -> 20260904T131022-ac38b6 (event=SessionStart, source=startup)
71
+ 2026-09-04T04:10:22.681Z INFO armed watch /home/junghan/.pi/agent/meta-mailbox/20260904T131022-ac38b6/inbox.signal
72
+ 2026-09-04T04:10:22.682Z INFO receiver marker 20260904T131022-ac38b6 owner=564537 arm=SessionStart source=startup
73
+ 2026-09-04T04:11:00.196Z INFO attach record 20260904T131022-ac38b6.meta.json (event=UserPromptSubmit, source=(unset), native=bbba9cbe-f11c-4b67-a0bd-991c3f678238)
74
+ 2026-09-04T04:11:00.198Z INFO sender marker 564537 -> 20260904T131022-ac38b6 (event=UserPromptSubmit, source=(unset))
75
+ ```
76
+
77
+ **S2 — `claude --resume`, picker, Enter.** Nothing at all is logged while the picker is open; the
78
+ single SessionStart lands on selection, already carrying the real native id. **No placeholder.**
79
+
80
+ ```
81
+ 2026-09-04T04:12:24.853Z INFO attach record 20260904T131022-ac38b6.meta.json (event=SessionStart, source=resume, native=bbba9cbe-f11c-4b67-a0bd-991c3f678238)
82
+ 2026-09-04T04:12:24.855Z INFO sender marker 565726 -> 20260904T131022-ac38b6 (event=SessionStart, source=resume)
83
+ 2026-09-04T04:12:24.855Z INFO armed watch /home/junghan/.pi/agent/meta-mailbox/20260904T131022-ac38b6/inbox.signal
84
+ 2026-09-04T04:12:24.855Z INFO receiver marker 20260904T131022-ac38b6 owner=565726 arm=SessionStart source=resume
85
+ ```
86
+
87
+ **S3 — `claude --resume <id>` (argv).** Same shape as S2:
88
+
89
+ ```
90
+ 2026-09-04T04:14:53.710Z INFO attach record 20260904T131022-ac38b6.meta.json (event=SessionStart, source=resume, native=bbba9cbe-f11c-4b67-a0bd-991c3f678238)
91
+ 2026-09-04T04:14:53.711Z INFO sender marker 568726 -> 20260904T131022-ac38b6 (event=SessionStart, source=resume)
92
+ 2026-09-04T04:14:53.711Z INFO armed watch /home/junghan/.pi/agent/meta-mailbox/20260904T131022-ac38b6/inbox.signal
93
+ 2026-09-04T04:14:53.712Z INFO receiver marker 20260904T131022-ac38b6 owner=568726 arm=SessionStart source=resume
94
+ ```
95
+
96
+ **S4 — THE cell: bare `claude`, then `/resume`.** This is #101 reproduced deliberately, and the
97
+ repair firing on it. One pid (566479), two gardens, 33 seconds apart:
98
+
99
+ ```
100
+ 2026-09-04T04:13:04.371Z INFO create record 20260904T131304-f85f0e.meta.json (event=SessionStart, source=startup, native=aa49d155-8416-41a5-9d59-52d747b428d7)
101
+ 2026-09-04T04:13:04.372Z INFO sender marker 566479 -> 20260904T131304-f85f0e (event=SessionStart, source=startup)
102
+ 2026-09-04T04:13:04.373Z INFO armed watch /home/junghan/.pi/agent/meta-mailbox/20260904T131304-f85f0e/inbox.signal
103
+ 2026-09-04T04:13:04.373Z INFO receiver marker 20260904T131304-f85f0e owner=566479 arm=SessionStart source=startup
104
+ 2026-09-04T04:13:37.966Z INFO attach record 20260904T131022-ac38b6.meta.json (event=SessionStart, source=resume, native=bbba9cbe-f11c-4b67-a0bd-991c3f678238)
105
+ 2026-09-04T04:13:37.967Z INFO retired receiver marker 20260904T131304-f85f0e — owner pid 566479 switched to 20260904T131022-ac38b6 (event=SessionStart, source=resume)
106
+ 2026-09-04T04:13:37.968Z INFO sender marker 566479 -> 20260904T131022-ac38b6 (event=SessionStart, source=resume)
107
+ 2026-09-04T04:13:37.969Z INFO armed watch /home/junghan/.pi/agent/meta-mailbox/20260904T131022-ac38b6/inbox.signal
108
+ 2026-09-04T04:13:37.970Z INFO receiver marker 20260904T131022-ac38b6 owner=566479 arm=SessionStart source=resume
109
+ ```
110
+
111
+ State immediately after, read from the real store (read-only):
112
+
113
+ ```
114
+ - 20260904T131022-ac38b6 backend=claude-code liveness=unsupported receiver=active transcript=exists cwd=…/scratchpad/lab
115
+ - 20260904T131304-f85f0e backend=claude-code liveness=unsupported receiver=none transcript=absent cwd=…/scratchpad/lab
116
+ ```
117
+
118
+ Both records survive. `meta-receivers/` holds only `20260904T131022-ac38b6.json`, and
119
+ `meta-senders/claude-code/566479.json` names `20260904T131022-ac38b6`. Before the repair the
120
+ abandoned garden would have read `receiver=active` and accepted mail — that is the #101 defect,
121
+ and this is the same shape as the field case (`ac7a1a`, still on this host as evidence).
122
+
123
+ **S5 — `/clear`.** Same switch shape, `source=clear`, and the retirement fires again:
124
+
125
+ ```
126
+ 2026-09-04T04:14:18.703Z INFO create record 20260904T131418-31129e.meta.json (event=SessionStart, source=clear, native=eac3c9ae-2873-454b-90ca-11b7ee24b508)
127
+ 2026-09-04T04:14:18.705Z INFO retired receiver marker 20260904T131022-ac38b6 — owner pid 566479 switched to 20260904T131418-31129e (event=SessionStart, source=clear)
128
+ 2026-09-04T04:14:18.706Z INFO sender marker 566479 -> 20260904T131418-31129e (event=SessionStart, source=clear)
129
+ 2026-09-04T04:14:18.706Z INFO armed watch /home/junghan/.pi/agent/meta-mailbox/20260904T131418-31129e/inbox.signal
130
+ 2026-09-04T04:14:18.706Z INFO receiver marker 20260904T131418-31129e owner=566479 arm=SessionStart source=clear
131
+ ```
132
+
133
+ **S6a — manual `/compact`.** SAME native id, SAME garden, no retirement:
134
+
135
+ ```
136
+ 2026-09-04T04:15:29.240Z INFO attach record 20260904T131022-ac38b6.meta.json (event=SessionStart, source=compact, native=bbba9cbe-f11c-4b67-a0bd-991c3f678238)
137
+ 2026-09-04T04:15:29.242Z INFO sender marker 568726 -> 20260904T131022-ac38b6 (event=SessionStart, source=compact)
138
+ 2026-09-04T04:15:29.242Z INFO armed watch /home/junghan/.pi/agent/meta-mailbox/20260904T131022-ac38b6/inbox.signal
139
+ 2026-09-04T04:15:29.243Z INFO receiver marker 20260904T131022-ac38b6 owner=568726 arm=SessionStart source=compact
140
+ ```
141
+
142
+ **S6b — automatic compaction.** Reached by having the session read large repo files until the
143
+ context filled: 60% → 80% → 91% → 98% (196.5K/200K of a 200K window), then one more large read
144
+ tipped it. Byte-identical shape to S6a; context fell to 72.1K afterwards:
145
+
146
+ ```
147
+ 2026-09-04T04:29:06.583Z INFO attach record 20260904T131022-ac38b6.meta.json (event=SessionStart, source=compact, native=bbba9cbe-f11c-4b67-a0bd-991c3f678238)
148
+ 2026-09-04T04:29:06.585Z INFO sender marker 568726 -> 20260904T131022-ac38b6 (event=SessionStart, source=compact)
149
+ 2026-09-04T04:29:06.586Z INFO armed watch /home/junghan/.pi/agent/meta-mailbox/20260904T131022-ac38b6/inbox.signal
150
+ 2026-09-04T04:29:06.587Z INFO receiver marker 20260904T131022-ac38b6 owner=568726 arm=SessionStart source=compact
151
+ ```
152
+
153
+ Manual and automatic compaction are the same event to this hook. Note it needs ~98% of the window
154
+ before it fires, and that a session under a smaller model or a shorter window will reach it sooner
155
+ — the threshold is the vendor's, not ours.
156
+
157
+ ## How to re-run
158
+
159
+ `tmux new-session -d -s lab -c <scratch> 'claude --model haiku'`, drive it with `send-keys` (send
160
+ the text and the `Enter` as SEPARATE calls — a combined one leaves the prompt typed but
161
+ unsubmitted), and read the delta of `~/.pi/agent/meta-bridge-hook.log` between cells. Nothing else
162
+ is needed: the hook log is the instrument. A cell costs a few seconds of a cheap model, except
163
+ S6b, which costs one full context window.
164
+
165
+ ## Boundary
166
+
167
+ One host (oracle), one vendor version (2.1.260), one model (Haiku 4.5), one operator's config.
168
+ The cells say what this Claude does here; they do not certify another host, an older Claude, or
169
+ a different terminal. The repair does not rest on them — it rests on the sender marker — which is
170
+ why a refutation here (S2/S3) corrected the prose and left the code alone.
@@ -51,7 +51,11 @@ import * as os from "node:os";
51
51
  import * as path from "node:path";
52
52
  import * as readline from "node:readline";
53
53
  import { fileURLToPath } from "node:url";
54
- import { upsertMetaSession, writeMetaReceiverMarker } from "../pi-extensions/lib/meta-session.ts";
54
+ import {
55
+ upsertMetaSession,
56
+ writeMetaReceiverMarker,
57
+ writeMetaSenderMarker,
58
+ } from "../pi-extensions/lib/meta-session.ts";
55
59
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
56
60
  import { skipLive } from "./lib/live-skip.ts";
57
61
  import { PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
@@ -210,7 +214,8 @@ async function main(): Promise<void> {
210
214
  const sessionsDir = path.join(world, "sessions");
211
215
  const mailboxDir = path.join(world, "mailbox");
212
216
  const receiversDir = path.join(world, "receivers");
213
- for (const d of [sessionsDir, mailboxDir, receiversDir]) await fsp.mkdir(d, { recursive: true });
217
+ const sendersDir = path.join(world, "senders");
218
+ for (const d of [sessionsDir, mailboxDir, receiversDir, sendersDir]) await fsp.mkdir(d, { recursive: true });
214
219
 
215
220
  const nonce = `CORTEXV2-${crypto.randomBytes(5).toString("hex").toUpperCase()}`;
216
221
 
@@ -224,6 +229,12 @@ async function main(): Promise<void> {
224
229
  dir: sessionsDir,
225
230
  });
226
231
  const receiverGid = receiver.record.gardenId;
232
+ // The receiver marker alone is no longer the whole fixture (#101): a `claude-code-cli` watch
233
+ // owner is deliverable only while ITS OWN sender marker still names the garden it is armed
234
+ // for — that join is what refuses a session which switched away in place. A receiver marker
235
+ // with no sender marker beside it models a state the product now treats as retired, so this
236
+ // smoke would fail on its own fixture instead of on the rail it exists to prove. Same shape
237
+ // as smoke-mux-lifecycle-live / smoke-omp-fresh-live, which already seeded both.
227
238
  writeMetaReceiverMarker({
228
239
  gardenId: receiverGid,
229
240
  backend: "claude-code",
@@ -232,6 +243,14 @@ async function main(): Promise<void> {
232
243
  armProvenance: "session-start",
233
244
  receiversDir,
234
245
  });
246
+ writeMetaSenderMarker({
247
+ backend: "claude-code",
248
+ gardenId: receiverGid,
249
+ nativeSessionId: receiver.record.nativeSessionId,
250
+ cwd: world,
251
+ ownerPid: process.pid,
252
+ sendersDir,
253
+ });
235
254
  console.error(`[smoke-acp-cortex-live] peer: ${receiverGid} (armed mailbox receiver)`);
236
255
 
237
256
  let senderGid = "";
@@ -252,6 +271,7 @@ async function main(): Promise<void> {
252
271
  ENTWURF_META_SESSIONS_DIR: sessionsDir,
253
272
  ENTWURF_META_MAILBOX_DIR: mailboxDir,
254
273
  ENTWURF_META_RECEIVERS_DIR: receiversDir,
274
+ ENTWURF_META_SENDERS_DIR: sendersDir,
255
275
  },
256
276
  },
257
277
  );
@@ -3,7 +3,7 @@
3
3
  // LIVE=1 ./run.sh smoke-acp-raw-turn-live
4
4
  //
5
5
  // What this proves (and ONLY this): the pinned Claude ACP adapter
6
- // (@agentclientprotocol/claude-agent-acp@0.73.0) spawns, speaks the ACP wire
6
+ // (@agentclientprotocol/claude-agent-acp@0.75.1) spawns, speaks the ACP wire
7
7
  // protocol over stdio NDJSON, and returns one real model turn. It is the
8
8
  // bytes-flow proof that the S2a dep surface is not just installable but
9
9
  // actually drivable — before any provider/overlay/streamSimple code (S2b+).
@@ -79,7 +79,11 @@ import * as os from "node:os";
79
79
  import * as path from "node:path";
80
80
  import * as readline from "node:readline";
81
81
  import { fileURLToPath } from "node:url";
82
- import { upsertMetaSession, writeMetaReceiverMarker } from "../pi-extensions/lib/meta-session.ts";
82
+ import {
83
+ upsertMetaSession,
84
+ writeMetaReceiverMarker,
85
+ writeMetaSenderMarker,
86
+ } from "../pi-extensions/lib/meta-session.ts";
83
87
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
84
88
  import { skipLive } from "./lib/live-skip.ts";
85
89
  import { PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
@@ -210,7 +214,8 @@ async function main(): Promise<void> {
210
214
  const sessionsDir = path.join(world, "sessions");
211
215
  const mailboxDir = path.join(world, "mailbox");
212
216
  const receiversDir = path.join(world, "receivers");
213
- for (const d of [sessionsDir, mailboxDir, receiversDir]) await fsp.mkdir(d, { recursive: true });
217
+ const sendersDir = path.join(world, "senders");
218
+ for (const d of [sessionsDir, mailboxDir, receiversDir, sendersDir]) await fsp.mkdir(d, { recursive: true });
214
219
 
215
220
  const nonce = `ACPV2-${crypto.randomBytes(5).toString("hex").toUpperCase()}`;
216
221
 
@@ -225,6 +230,13 @@ async function main(): Promise<void> {
225
230
  });
226
231
  const receiverGid = receiver.record.gardenId;
227
232
  // armed = a presence marker owned by THIS (live) process → deliverable.
233
+ //
234
+ // The receiver marker alone is no longer the whole fixture (#101): a `claude-code-cli` watch
235
+ // owner is deliverable only while ITS OWN sender marker still names the garden it is armed
236
+ // for — that join is what refuses a session which switched away in place. A receiver marker
237
+ // with no sender marker beside it models a state the product now treats as retired, so this
238
+ // smoke would fail on its own fixture instead of on the rail it exists to prove. Same shape
239
+ // as smoke-mux-lifecycle-live / smoke-omp-fresh-live, which already seeded both.
228
240
  writeMetaReceiverMarker({
229
241
  gardenId: receiverGid,
230
242
  backend: "claude-code",
@@ -233,6 +245,14 @@ async function main(): Promise<void> {
233
245
  armProvenance: "session-start",
234
246
  receiversDir,
235
247
  });
248
+ writeMetaSenderMarker({
249
+ backend: "claude-code",
250
+ gardenId: receiverGid,
251
+ nativeSessionId: receiver.record.nativeSessionId,
252
+ cwd: world,
253
+ ownerPid: process.pid,
254
+ sendersDir,
255
+ });
236
256
  console.error(`[smoke-acp-v2-send-live] peer: ${receiverGid} (armed mailbox receiver)`);
237
257
 
238
258
  let senderGid = "";
@@ -252,6 +272,7 @@ async function main(): Promise<void> {
252
272
  ENTWURF_META_SESSIONS_DIR: sessionsDir,
253
273
  ENTWURF_META_MAILBOX_DIR: mailboxDir,
254
274
  ENTWURF_META_RECEIVERS_DIR: receiversDir,
275
+ ENTWURF_META_SENDERS_DIR: sendersDir,
255
276
  },
256
277
  },
257
278
  );