@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.
- package/AGENTS.md +6 -5
- package/CHANGELOG.md +299 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +9 -5
- package/VERIFY.md +3 -3
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +46 -12
- package/docs/setup-clean-host.md +35 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +14 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +9 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +11 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +66 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +10 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +8 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +25 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +6 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +38 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +64 -4
- package/mcp/entwurf-bridge/src/index.ts +16 -8
- package/package.json +10 -9
- package/pi-extensions/entwurf-control.ts +12 -2
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +3 -3
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/entwurf-deliverability.ts +104 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +20 -6
- package/pi-extensions/lib/entwurf-facts.ts +52 -3
- package/pi-extensions/lib/entwurf-peer-observe.ts +77 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +12 -1
- package/pi-extensions/lib/entwurf-v2-decider.ts +26 -8
- package/pi-extensions/lib/entwurf-v2-production.ts +32 -12
- package/pi-extensions/lib/entwurf-v2-surface.ts +6 -0
- package/pi-extensions/lib/meta-session.ts +44 -0
- package/pi-extensions/meta-bridge-hook.ts +72 -3
- package/run.sh +146 -56
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-bridge-delivery.ts +42 -1
- package/scripts/check-entwurf-fact-provider.ts +22 -1
- package/scripts/check-entwurf-facts.ts +17 -2
- package/scripts/check-entwurf-peers-surface.ts +20 -2
- package/scripts/check-entwurf-self-address.ts +20 -2
- package/scripts/check-entwurf-v2-decider.ts +6 -3
- package/scripts/check-entwurf-v2-matrix.ts +10 -5
- package/scripts/check-entwurf-v2-production.ts +72 -1
- package/scripts/check-gate-qualification.ts +63 -12
- package/scripts/check-meta-hook-session-switch.ts +560 -0
- package/scripts/check-release-gate-outcomes.ts +160 -3
- package/scripts/meta-bridge-doctor.sh +29 -4
- package/scripts/mutants/bridge-command-boot.json +3 -3
- package/scripts/mutants/gate-qualification.json +28 -0
- package/scripts/mutants/meta-hook-session-switch.json +223 -0
- package/scripts/mutants/pack-install.json +14 -3
- package/scripts/mutants/release-gate.json +22 -0
- package/scripts/raw-acp-compaction-measure/README.md +77 -0
- package/scripts/raw-acp-compaction-measure/probe.ts +196 -0
- package/scripts/raw-claude-session-switch/README.md +170 -0
- package/scripts/smoke-acp-cortex-live.ts +22 -2
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-v2-send-live.ts +23 -2
- package/scripts/smoke-entwurf-chain-live.ts +85 -13
- package/scripts/smoke-entwurf-v2-matrix-live.ts +23 -1
- package/scripts/smoke-mux-fresh-call-live.ts +13 -3
|
@@ -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 {
|
|
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
|
|
276
|
-
//
|
|
277
|
-
//
|
|
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 =
|
|
302
|
-
|
|
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:
|
|
308
|
-
watchArmed:
|
|
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.
|
|
3
|
+
"version": "0.18.1",
|
|
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": {
|
|
@@ -82,23 +82,23 @@
|
|
|
82
82
|
"claudeCodeFloor": ">=2.1.217"
|
|
83
83
|
},
|
|
84
84
|
"dependencies": {
|
|
85
|
-
"@agentclientprotocol/claude-agent-acp": "0.
|
|
85
|
+
"@agentclientprotocol/claude-agent-acp": "0.75.1",
|
|
86
86
|
"@agentclientprotocol/sdk": "1.4.0",
|
|
87
87
|
"@anthropic-ai/sdk": "0.100.1",
|
|
88
88
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
89
89
|
"zod": "^3.25.0 || ^4.0.0"
|
|
90
90
|
},
|
|
91
91
|
"peerDependencies": {
|
|
92
|
-
"@earendil-works/pi-ai": ">=0.
|
|
93
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
94
|
-
"@earendil-works/pi-tui": ">=0.
|
|
92
|
+
"@earendil-works/pi-ai": ">=0.85.1 <0.86",
|
|
93
|
+
"@earendil-works/pi-coding-agent": ">=0.85.1 <0.86",
|
|
94
|
+
"@earendil-works/pi-tui": ">=0.85.1 <0.86",
|
|
95
95
|
"typebox": "*"
|
|
96
96
|
},
|
|
97
97
|
"devDependencies": {
|
|
98
98
|
"@biomejs/biome": "^2.4.13",
|
|
99
|
-
"@earendil-works/pi-ai": "0.
|
|
100
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
101
|
-
"@earendil-works/pi-tui": "0.
|
|
99
|
+
"@earendil-works/pi-ai": "0.85.1",
|
|
100
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
101
|
+
"@earendil-works/pi-tui": "0.85.1",
|
|
102
102
|
"@types/node": "^24.3.0",
|
|
103
103
|
"husky": "^9.1.7",
|
|
104
104
|
"rregex": "1.13.1",
|
|
@@ -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-birth-hook && ./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-gate-manifests && ./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": {
|
|
@@ -79,7 +79,7 @@ import type {
|
|
|
79
79
|
MessageRenderer,
|
|
80
80
|
} from "@earendil-works/pi-coding-agent";
|
|
81
81
|
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
82
|
-
import { Box, type
|
|
82
|
+
import { Box, type Component, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
83
83
|
import { ENTWURF_SENT_MESSAGE_TYPE } from "../protocol.js";
|
|
84
84
|
import { CONTROL_SOCKET_SUFFIX, controlSocketPathIn, defaultControlSocketDir } from "./lib/control-socket-path.js";
|
|
85
85
|
import {
|
|
@@ -548,7 +548,17 @@ interface SentBoxData {
|
|
|
548
548
|
// `expanded` truncates the body the same way as renderSessionMessage so a
|
|
549
549
|
// large send shows the same preview shape as a large receive. operators
|
|
550
550
|
// reading the transcript should not need different mental models.
|
|
551
|
-
|
|
551
|
+
// Return type is `Component`, the interface `MessageRenderer` actually asks for
|
|
552
|
+
// (`Component | undefined`, read at pi-coding-agent
|
|
553
|
+
// `dist/core/extensions/types.d.ts:889`) — NOT the concrete `Container`.
|
|
554
|
+
// `[측정 2026-09-06]` pi 0.85.0's mouse work gave `Container` a `private mouseLayout?`
|
|
555
|
+
// (`pi-tui dist/tui.d.ts:198`; 0.84.4's `Container` had no private member at all), and
|
|
556
|
+
// `Box` declares a SEPARATE private `mouseLayout` of its own. TypeScript only accepts a
|
|
557
|
+
// private member from the same declaration, so the structural assignment `Box -> Container`
|
|
558
|
+
// that held through 0.84.4 became TS2322 at 0.85.x. This was an UNDECLARED break — the
|
|
559
|
+
// upstream Breaking section names only `createGatewayBindingFetch`. Annotating the shared
|
|
560
|
+
// interface is the honest fix: nothing here ever needed Container's own surface.
|
|
561
|
+
const buildSentMessageBox = (data: SentBoxData, expanded: boolean, theme: Theme): Component => {
|
|
552
562
|
let body = data.body || "(no content)";
|
|
553
563
|
if (!expanded) {
|
|
554
564
|
const lines = body.split("\n");
|
|
@@ -46,8 +46,8 @@ import type { AcpTextBlock } from "./context.js";
|
|
|
46
46
|
* shape) and never onto those four. A field rename upstream fails the typecheck
|
|
47
47
|
* here rather than silently zeroing the operator's cache-effect badge.
|
|
48
48
|
*/
|
|
49
|
-
/** One `_meta.quota.token_count` row (claude-agent-acp 0.
|
|
50
|
-
* `dist/acp-agent.js:
|
|
49
|
+
/** One `_meta.quota.token_count` row (claude-agent-acp 0.75.1
|
|
50
|
+
* `dist/acp-agent.js:6493-6502`). `cachedInputTokens` is cache READS — the name
|
|
51
51
|
* differs from `usage.cachedReadTokens` because the shape is shared with
|
|
52
52
|
* codex-acp; `cachedWriteTokens` is Claude's extra sibling. */
|
|
53
53
|
export type AcpQuotaTokenCount = {
|
|
@@ -64,7 +64,7 @@ export type AcpPromptResponse = {
|
|
|
64
64
|
/** Vendor-private, version-pinned. `_meta` is a standard ACP extension slot
|
|
65
65
|
* whose values a client may not assume, and `quota` is NOT in
|
|
66
66
|
* claude-agent-acp's exported types — it is produced by the private
|
|
67
|
-
* `turnQuotaMeta()` (read at 0.
|
|
67
|
+
* `turnQuotaMeta()` (read at 0.75.1 `dist/acp-agent.js:6476-6485`). Read
|
|
68
68
|
* defensively, never structurally required, and re-measure on a pin move. */
|
|
69
69
|
_meta?: {
|
|
70
70
|
quota?: {
|
|
@@ -150,7 +150,7 @@ export interface AcpAdapterSettingsParams {
|
|
|
150
150
|
*
|
|
151
151
|
* During stream handling the vendor keeps a per-message snapshot in
|
|
152
152
|
* `lastAssistantUsage`, but its `usage_update` publishes only the scalar `used`
|
|
153
|
-
* value (read at claude-agent-acp 0.
|
|
153
|
+
* value (read at claude-agent-acp 0.75.1 `dist/acp-agent.js:3853-3878`). Until
|
|
154
154
|
* that partition is carried on the wire, NOTHING honest can go in pi's four
|
|
155
155
|
* fields, so entwurf writes none of them.
|
|
156
156
|
*/
|
|
@@ -342,8 +342,8 @@ export const claudeAdapter: AcpBackendAdapter = {
|
|
|
342
342
|
|
|
343
343
|
// MEASURED (2026-09-02, #93): claude-agent-acp's `usage_update` carries a
|
|
344
344
|
// session-cumulative ESTIMATED cost (`cost.amount = message.total_cost_usd`)
|
|
345
|
-
// and a context-occupancy scalar (`used`; both read at 0.
|
|
346
|
-
// `dist/acp-agent.js:
|
|
345
|
+
// and a context-occupancy scalar (`used`; both read at 0.75.1
|
|
346
|
+
// `dist/acp-agent.js:3467-3482`). Both are measured, so backend.ts seals them.
|
|
347
347
|
// The token partition is NOT declared here: ACP's only token carrier is a
|
|
348
348
|
// per-turn round-trip aggregate, which is not what pi's four fields mean.
|
|
349
349
|
sealsTurnAccounting: true,
|
|
@@ -137,7 +137,7 @@ function finiteOrZero(value: unknown): number {
|
|
|
137
137
|
* "the accounting-grade figure per the SDK" — it also counts Task subagents,
|
|
138
138
|
* sidechains, and INTERNAL CALLS SUCH AS COMPACTION, so its rows "can total more
|
|
139
139
|
* than `token_count`" and are "the fuller picture, not a decomposition of it"
|
|
140
|
-
* (read at claude-agent-acp 0.
|
|
140
|
+
* (read at claude-agent-acp 0.75.1 `dist/acp-agent.js:6465-6485`). The narrower
|
|
141
141
|
* `PromptResponse.usage` (== `quota.token_count`) is the MAIN AGENT LOOP only.
|
|
142
142
|
*
|
|
143
143
|
* The wider one is the right numerator because the denominator already has that
|
|
@@ -1237,7 +1237,7 @@ export function streamAcpTurn(
|
|
|
1237
1237
|
// Writing zeros is not a placeholder for a better number we could compute:
|
|
1238
1238
|
// the per-request partition is genuinely absent from the wire. The vendor
|
|
1239
1239
|
// builds it in `lastAssistantUsage` and sends only its scalar sum (read at
|
|
1240
|
-
// claude-agent-acp 0.
|
|
1240
|
+
// claude-agent-acp 0.75.1 `dist/acp-agent.js:3853-3878`) — #96.
|
|
1241
1241
|
//
|
|
1242
1242
|
// But silence is NOT the resting state. A cache miss the operator never sees
|
|
1243
1243
|
// is a false reading, not a modest one: a session can run for hours believing
|
|
@@ -1356,7 +1356,7 @@ export function streamAcpTurn(
|
|
|
1356
1356
|
// between them is exactly why the notice below names a MECHANISM and
|
|
1357
1357
|
// never a cause: claude-agent-acp's `conversation_reset` handler only
|
|
1358
1358
|
// switches the SDK to a fresh conversation and touches no cost at all
|
|
1359
|
-
// (read at 0.
|
|
1359
|
+
// (read at 0.75.1 `dist/acp-agent.js:4282-4289`), while claude-agent-sdk
|
|
1360
1360
|
// separately documents that "a mid-session /clear resets the running
|
|
1361
1361
|
// total" (read at 0.3.257 `sdk.d.ts:4884`). A reset therefore PLAUSIBLY
|
|
1362
1362
|
// explains a backwards total, but nothing here has MEASURED that it did,
|
|
@@ -60,8 +60,8 @@ export type AcpPiStreamState = {
|
|
|
60
60
|
*
|
|
61
61
|
* Last write wins, never a sum: both are latest session-level observations, and
|
|
62
62
|
* one turn can legitimately see several (claude emits one per `result` message,
|
|
63
|
-
* including a sub-agent's own — read at claude-agent-acp 0.
|
|
64
|
-
* `dist/acp-agent.js:
|
|
63
|
+
* including a sub-agent's own — read at claude-agent-acp 0.75.1
|
|
64
|
+
* `dist/acp-agent.js:3467-3482`), each carrying that result's current values.
|
|
65
65
|
*/
|
|
66
66
|
observedSessionCostUsd?: number;
|
|
67
67
|
observedContextOccupancyTokens?: number;
|
|
@@ -346,9 +346,9 @@ export function applyAcpSessionUpdate(
|
|
|
346
346
|
case "usage_update": {
|
|
347
347
|
// `used` is OCCUPANCY-shaped — the backend's post-turn context size, not
|
|
348
348
|
// the prompt response's turn aggregate. Claude sends `lastAssistantTotalUsage`
|
|
349
|
-
// as `used` (read at claude-agent-acp 0.
|
|
350
|
-
// `dist/acp-agent.js:
|
|
351
|
-
// latest assistant snapshot (`:
|
|
349
|
+
// as `used` (read at claude-agent-acp 0.75.1
|
|
350
|
+
// `dist/acp-agent.js:3867-3878`), after constructing that scalar from the
|
|
351
|
+
// latest assistant snapshot (`:3853-3866`). pi reads `usage.totalTokens` as
|
|
352
352
|
// exactly that occupancy (`calculateContextTokens(usage) = usage.totalTokens
|
|
353
353
|
// || input + output + cacheRead + cacheWrite`, read at pi-coding-agent
|
|
354
354
|
// `dist/core/compaction/compaction.js:86-88`). The assignment below is the
|
|
@@ -109,6 +109,110 @@ export function receiverMarkerMatchesIdentity(
|
|
|
109
109
|
);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// ── the watch-owner ↔ sender-marker join (#101 결함 B) ──────────────────────
|
|
113
|
+
// A receiver marker proves "a LIVE process once armed a watch for this garden".
|
|
114
|
+
// It does NOT prove "that process is serving this garden RIGHT NOW". One native
|
|
115
|
+
// process can hold markers for several gardens and drain exactly one of them:
|
|
116
|
+
// Claude Code's session switch (an in-session `/resume` or `/clear`) fires a second
|
|
117
|
+
// SessionStart inside the SAME pid under a NEW native session id, so the first
|
|
118
|
+
// garden's marker keeps naming a live owner forever while the doorbell it advertises
|
|
119
|
+
// is gone. Measured on oracle 2026-09-04 (#101): one pid held both markers, a
|
|
120
|
+
// 09:33 enqueue to the retired garden sat unread as a bare `.msg` for ≥50 minutes —
|
|
121
|
+
// the "enqueue into a void" this module's header exists to refuse.
|
|
122
|
+
//
|
|
123
|
+
// The join that decides it is already on disk: the SENDER marker is keyed by owner
|
|
124
|
+
// pid and rewritten with the CURRENT garden on every arm/prompt, so
|
|
125
|
+
// `meta-senders/<backend>/<ownerPid>.json.gardenId` is the answer to "which garden
|
|
126
|
+
// does this process serve now?". A receiver marker naming a different garden than
|
|
127
|
+
// its own owner's sender marker is a retired watch.
|
|
128
|
+
//
|
|
129
|
+
// SCOPE IS NOT UNIVERSAL, AND THE MARKER SAYS SO. The join only exists where the
|
|
130
|
+
// watch owner IS the process the sender marker is keyed to. That is true for the
|
|
131
|
+
// Claude hook (one `ownerPid` variable writes both markers —
|
|
132
|
+
// `meta-bridge-hook.ts` sender + receiver arm) and false BY CONSTRUCTION for
|
|
133
|
+
// Copilot, whose watch lives in a forked first-party extension child
|
|
134
|
+
// (`extension.mjs` writes `ownerPid: process.pid`) while its sender marker is keyed
|
|
135
|
+
// to the CLI parent (`meta-bridge-hook-copilot.ts` uses `process.ppid`). Applying
|
|
136
|
+
// the join there would make every Copilot citizen permanently undeliverable — a
|
|
137
|
+
// regression on a shipped lane, not a fix. `ownerKind` is recorded on the marker for
|
|
138
|
+
// exactly this reason ("the marker records which, because the pid a reader verifies
|
|
139
|
+
// differs" — AGENTS.md, self-fetch domain), so it is the axis, not `backend`.
|
|
140
|
+
//
|
|
141
|
+
// `omp-host` is a CANDIDATE, deliberately not admitted here: OMP already retires the
|
|
142
|
+
// previous garden in-process on its `/new` edge (the unarm the claude hook lacked),
|
|
143
|
+
// so the cell this join closes has a different owner there. Admitting it needs its
|
|
144
|
+
// own measurement, not this file's optimism.
|
|
145
|
+
export const SENDER_JOINED_RECEIVER_OWNER_KINDS: readonly string[] = ["claude-code-cli"];
|
|
146
|
+
|
|
147
|
+
/** Does this watch owner share its pid with the backend's sender marker? */
|
|
148
|
+
export function receiverOwnerKindJoinsSender(ownerKind: string): boolean {
|
|
149
|
+
return SENDER_JOINED_RECEIVER_OWNER_KINDS.includes(ownerKind);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The receiver-marker fields the join reads (a structural shape, like ReceiverIdentityFacts). */
|
|
153
|
+
export interface ReceiverOwnerFacts extends ReceiverIdentityFacts {
|
|
154
|
+
ownerPid: number;
|
|
155
|
+
ownerKind: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The sender-marker fields the join reads: which garden this owner pid serves NOW. */
|
|
159
|
+
export interface SenderOwnerFacts {
|
|
160
|
+
gardenId: string;
|
|
161
|
+
backend: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Is the watch owner still serving THIS garden? Fail-closed inside the join's scope:
|
|
166
|
+
* an absent/unreadable sender marker, or one naming another garden or backend, means
|
|
167
|
+
* the watch is retired. Outside the scope (an ownerKind whose watch owner is not the
|
|
168
|
+
* sender-marker process) the join does not apply and the marker's own live-owner guard
|
|
169
|
+
* is the whole rule — returning true here is NOT optimism, it is "this axis says
|
|
170
|
+
* nothing", and the caller has already required the marker to match the identity.
|
|
171
|
+
*/
|
|
172
|
+
export function receiverOwnerServesGarden(
|
|
173
|
+
marker: ReceiverOwnerFacts,
|
|
174
|
+
senderMarker: SenderOwnerFacts | null | undefined,
|
|
175
|
+
): boolean {
|
|
176
|
+
if (!receiverOwnerKindJoinsSender(marker.ownerKind)) return true;
|
|
177
|
+
return !!senderMarker && senderMarker.gardenId === marker.gardenId && senderMarker.backend === marker.backend;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The two marker readers the mailbox receiver facts are composed from (injected — this module does no IO). */
|
|
181
|
+
export interface MailboxReceiverReaders {
|
|
182
|
+
/** Read the receiver presence marker for a garden id (null = absent/corrupt/dead owner). */
|
|
183
|
+
readReceiverMarker: (gardenId: string) => ReceiverOwnerFacts | null;
|
|
184
|
+
/** Read the sender marker for an owner pid (null = absent/corrupt/dead owner). */
|
|
185
|
+
readSenderMarker: (backend: string, ownerPid: number) => SenderOwnerFacts | null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* THE single composition of the two mailbox receiver facts, over injected readers.
|
|
190
|
+
* Both production consumers — the v2 `mailboxDeliverabilityFor` seam and the MCP
|
|
191
|
+
* bridge's `entwurf_self` — call THIS, so a direct send, a re-resolved fallback send
|
|
192
|
+
* and a citizen's own replyability can never drift to different verdicts.
|
|
193
|
+
*
|
|
194
|
+
* ownerAlive — a live-owner marker that names THIS identity (the reader already ran
|
|
195
|
+
* the plausibility + start-key guards; this adds the identity match).
|
|
196
|
+
* watchArmed — that owner is still serving this garden (the join above). It is a
|
|
197
|
+
* MEASUREMENT, never a copy of ownerAlive: copying it is what let a
|
|
198
|
+
* retired watch read as an armed doorbell (#101 결함 B).
|
|
199
|
+
*
|
|
200
|
+
* `recordBacked` is NOT decided here — it stays the caller's explicit fact, so an
|
|
201
|
+
* absent record and a dead owner stay distinguishable in the reason string.
|
|
202
|
+
*/
|
|
203
|
+
export function resolveMailboxReceiverFacts(
|
|
204
|
+
identity: ReceiverIdentityFacts,
|
|
205
|
+
readers: MailboxReceiverReaders,
|
|
206
|
+
): { ownerAlive: boolean; watchArmed: boolean } {
|
|
207
|
+
const marker = readers.readReceiverMarker(identity.gardenId);
|
|
208
|
+
const ownerAlive = receiverMarkerMatchesIdentity(marker, identity);
|
|
209
|
+
if (!ownerAlive || !marker) return { ownerAlive: false, watchArmed: false };
|
|
210
|
+
const senderMarker = receiverOwnerKindJoinsSender(marker.ownerKind)
|
|
211
|
+
? readers.readSenderMarker(marker.backend, marker.ownerPid)
|
|
212
|
+
: null;
|
|
213
|
+
return { ownerAlive, watchArmed: receiverOwnerServesGarden(marker, senderMarker) };
|
|
214
|
+
}
|
|
215
|
+
|
|
112
216
|
export interface MailboxDeliverabilityFacts extends MetaReceiverActiveFacts {
|
|
113
217
|
/** The target backend's wake mode (from the capability registry). */
|
|
114
218
|
wakeMode?: WakeMode | string;
|
|
@@ -27,7 +27,13 @@
|
|
|
27
27
|
* one diagnostic carries the fact. (pi + same-gid socket = the normal merge.)
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
type FactList,
|
|
32
|
+
isOutOfSocketDomainGardenIdConflict,
|
|
33
|
+
type PeerObserver,
|
|
34
|
+
resolveFactList,
|
|
35
|
+
} from "./entwurf-facts.ts";
|
|
36
|
+
import { observePeerFacts } from "./entwurf-peer-observe.ts";
|
|
31
37
|
import { isLivenessSupported } from "./entwurf-v2-contract.ts";
|
|
32
38
|
import {
|
|
33
39
|
type ActiveStoreEntry,
|
|
@@ -93,6 +99,12 @@ export interface EntwurfFactsDeps {
|
|
|
93
99
|
readRecord: (filename: string) => string;
|
|
94
100
|
/** Socket axis: injected into scanSocketProbes (controlDir/readdir/probe). */
|
|
95
101
|
socket?: Partial<SocketScanDeps>;
|
|
102
|
+
/** Observation axis (#101): per-citizen receiver + transcript facts. Defaults to the
|
|
103
|
+
* REAL measurement — the same seam shape `makeProductionEntwurfV2Deps` uses, so the two
|
|
104
|
+
* wiring sites (MCP + pi-native) cannot drift by each passing their own observer, and a
|
|
105
|
+
* gate still drives the assembly with a fake and no filesystem. A caller that injects
|
|
106
|
+
* nothing gets facts; a caller that injects `() => UNOBSERVED_PEER` says so on every row. */
|
|
107
|
+
observe?: PeerObserver;
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
function diagnosticSortKey(d: EntwurfDiagnostic): string {
|
|
@@ -113,10 +125,12 @@ function diagnosticSortKey(d: EntwurfDiagnostic): string {
|
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
/**
|
|
116
|
-
* Assemble the facts-only listing.
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
128
|
+
* Assemble the facts-only listing. Driven entirely through injected deps, so the gate
|
|
129
|
+
* runs it without a filesystem; slice 4c supplies the real readdir / readFile / probe.
|
|
130
|
+
* The one dep with a REAL default is `observe` (#101) — see its comment: a per-citizen
|
|
131
|
+
* measurement both wiring sites must make identically is safer as one default than as two
|
|
132
|
+
* call-site arguments. Probes are gid + liveness only (#50 C4 — the per-socket get_info
|
|
133
|
+
* enrich left with the socket-only quasi-citizen listing).
|
|
120
134
|
*/
|
|
121
135
|
export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfFactsResult> {
|
|
122
136
|
const diagnostics: EntwurfDiagnostic[] = [];
|
|
@@ -183,7 +197,7 @@ export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfF
|
|
|
183
197
|
// fire as the last line of defense, never caught here.
|
|
184
198
|
const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
|
|
185
199
|
const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
|
|
186
|
-
const facts: FactList = resolveFactList(cleanIdentities, cleanProbes);
|
|
200
|
+
const facts: FactList = resolveFactList(cleanIdentities, cleanProbes, deps.observe ?? observePeerFacts);
|
|
187
201
|
|
|
188
202
|
// 5. #50 C4 demotion: a record-less socket is a diagnostic, not a listing
|
|
189
203
|
// section. One diagnostic per socket (subjects aggregate at render, F8);
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
* a peer-facing fact; `entwurf_peers` exposes identity + cwd-history, not
|
|
20
20
|
* filesystem internals. (who-can / dispatch read it via the meta-record
|
|
21
21
|
* directly when they genuinely need it — it does not belong in the listing.)
|
|
22
|
+
* Whether that transcript EXISTS is a different thing and is carried (#101):
|
|
23
|
+
* the path stays private, the fact that a citizen has never written a turn
|
|
24
|
+
* does not.
|
|
22
25
|
*
|
|
23
26
|
* The 4-value liveness (`alive|dead|indeterminate|unsupported`, R3b) and the
|
|
24
27
|
* out-of-domain → `unsupported` rule (R1: never coerce an unprobed backend to
|
|
@@ -47,8 +50,44 @@ export interface PeerFact {
|
|
|
47
50
|
recordUpdatedAt: string;
|
|
48
51
|
// — the single computed fact: 4-value liveness (R1/R3b). NOT a verb. —
|
|
49
52
|
liveness: FactLiveness;
|
|
53
|
+
// — observed state (#101). Facts, not verbs: they say what was found on disk, not
|
|
54
|
+
// what a caller may do about it. For claude-code every row reads
|
|
55
|
+
// `liveness=unsupported` — the control-socket probe does not apply — so the listing
|
|
56
|
+
// showed nothing that separated a live citizen from a phantom, and a caller picking
|
|
57
|
+
// "the newest record in this cwd" picked the phantom as often as the real one.
|
|
58
|
+
receiver: ReceiverObservation;
|
|
59
|
+
transcript: TranscriptObservation;
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The mailbox receiver axis, as OBSERVED for this citizen.
|
|
64
|
+
* active a presence marker whose owner is live AND still serving this garden.
|
|
65
|
+
* inactive a marker exists but fails that test (dead owner, drifted identity, or an
|
|
66
|
+
* owner that switched to another garden).
|
|
67
|
+
* none no marker at all — never armed, or retired.
|
|
68
|
+
* n/a this backend has no mailbox receiver axis (pi, antigravity).
|
|
69
|
+
* unobserved nobody measured it. Only reachable when a caller drives the fact core
|
|
70
|
+
* without an observer; it is the honest word for "not looked at", never a
|
|
71
|
+
* quiet stand-in for `none`.
|
|
72
|
+
*/
|
|
73
|
+
export type ReceiverObservation = "active" | "inactive" | "none" | "n/a" | "unobserved";
|
|
74
|
+
|
|
75
|
+
/** Does the recorded transcript exist on disk? `absent` is what a never-used registration
|
|
76
|
+
* looks like: a record with no conversation behind it. */
|
|
77
|
+
export type TranscriptObservation = "exists" | "absent" | "unobserved";
|
|
78
|
+
|
|
79
|
+
export interface PeerObservations {
|
|
80
|
+
receiver: ReceiverObservation;
|
|
81
|
+
transcript: TranscriptObservation;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What a caller that measured nothing must say. Explicit and greppable — a row that
|
|
85
|
+
* silently defaulted to `none`/`absent` would be a fabricated fact. */
|
|
86
|
+
export const UNOBSERVED_PEER: PeerObservations = { receiver: "unobserved", transcript: "unobserved" };
|
|
87
|
+
|
|
88
|
+
/** Measure the two observed axes for one citizen. Injected, so this module stays pure. */
|
|
89
|
+
export type PeerObserver = (identity: MetaIdentity) => PeerObservations;
|
|
90
|
+
|
|
52
91
|
/**
|
|
53
92
|
* Compose a `PeerFact` from a citizen's identity and an optional socket probe.
|
|
54
93
|
*
|
|
@@ -61,7 +100,11 @@ export interface PeerFact {
|
|
|
61
100
|
*
|
|
62
101
|
* Pure: same inputs → same output, no IO.
|
|
63
102
|
*/
|
|
64
|
-
export function resolvePeerFact(
|
|
103
|
+
export function resolvePeerFact(
|
|
104
|
+
identity: MetaIdentity,
|
|
105
|
+
socket: SocketLiveness | null,
|
|
106
|
+
observations: PeerObservations = UNOBSERVED_PEER,
|
|
107
|
+
): PeerFact {
|
|
65
108
|
return {
|
|
66
109
|
gardenId: identity.gardenId,
|
|
67
110
|
backend: identity.backend,
|
|
@@ -71,6 +114,8 @@ export function resolvePeerFact(identity: MetaIdentity, socket: SocketLiveness |
|
|
|
71
114
|
createdAt: identity.createdAt,
|
|
72
115
|
recordUpdatedAt: identity.recordUpdatedAt,
|
|
73
116
|
liveness: factLivenessOf(identity.backend, socket),
|
|
117
|
+
receiver: observations.receiver,
|
|
118
|
+
transcript: observations.transcript,
|
|
74
119
|
};
|
|
75
120
|
}
|
|
76
121
|
|
|
@@ -172,7 +217,11 @@ export interface FactList {
|
|
|
172
217
|
* A gardenId is never emitted as both a `PeerFact` and a `RecordLessSocketFact`;
|
|
173
218
|
* a record-less socket becomes a `PeerFact` the moment a record claims the gid.
|
|
174
219
|
*/
|
|
175
|
-
export function resolveFactList(
|
|
220
|
+
export function resolveFactList(
|
|
221
|
+
identities: MetaIdentity[],
|
|
222
|
+
socketProbes: SocketProbe[],
|
|
223
|
+
observe: PeerObserver = () => UNOBSERVED_PEER,
|
|
224
|
+
): FactList {
|
|
176
225
|
const probeMap = new Map<string, SocketProbe>();
|
|
177
226
|
for (const probe of socketProbes) {
|
|
178
227
|
if (probeMap.has(probe.gardenId)) {
|
|
@@ -207,7 +256,7 @@ export function resolveFactList(identities: MetaIdentity[], socketProbes: Socket
|
|
|
207
256
|
}
|
|
208
257
|
socket = null;
|
|
209
258
|
}
|
|
210
|
-
peers.push(resolvePeerFact(identity, socket));
|
|
259
|
+
peers.push(resolvePeerFact(identity, socket, observe(identity)));
|
|
211
260
|
consumed.add(gid);
|
|
212
261
|
}
|
|
213
262
|
|