@junghanacs/entwurf 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +60 -5
  3. package/CHANGELOG.md +557 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +52 -2
  6. package/FAQ.md +8 -4
  7. package/README.md +37 -17
  8. package/VERIFY.md +23 -10
  9. package/docs/acp-backend-rail.md +5 -1
  10. package/docs/external-mcp-host.md +8 -6
  11. package/docs/fresh-cut-policy.md +9 -1
  12. package/docs/setup-clean-host.md +64 -9
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +100 -24
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +10 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +123 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +259 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js +80 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
  36. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
  37. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  38. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  39. package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +65 -0
  40. package/mcp/entwurf-bridge/src/index.ts +118 -24
  41. package/mcp/entwurf-bridge/test.sh +2 -2
  42. package/mcp/entwurf-bridge/tsconfig.build.json +17 -1
  43. package/package.json +5 -5
  44. package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
  45. package/pi-extensions/entwurf-control.ts +55 -20
  46. package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  47. package/pi-extensions/lib/cc-retire-request.ts +553 -0
  48. package/pi-extensions/lib/control-retire.ts +195 -0
  49. package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
  50. package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
  51. package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
  52. package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
  53. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
  54. package/pi-extensions/lib/fresh-call-composition.ts +15 -0
  55. package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
  56. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  57. package/pi-extensions/lib/meta-mailbox-body.ts +13 -4
  58. package/pi-extensions/lib/meta-session.ts +140 -16
  59. package/pi-extensions/lib/mux-fresh-call.ts +7 -27
  60. package/pi-extensions/lib/mux-launch.ts +29 -0
  61. package/pi-extensions/lib/mux-resume-call.ts +33 -12
  62. package/pi-extensions/lib/pi-retire-request.ts +111 -0
  63. package/pi-extensions/lib/remote-pair-receive.ts +184 -0
  64. package/pi-extensions/lib/remote-pair-send.ts +325 -0
  65. package/pi-extensions/lib/remote-pair-transport.ts +81 -0
  66. package/pi-extensions/lib/resume-launch-identity.ts +104 -18
  67. package/pi-extensions/lib/retirement-event.ts +253 -0
  68. package/pi-extensions/meta-bridge-hook.ts +142 -13
  69. package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
  70. package/run.sh +122 -7
  71. package/scripts/agy-bridge-config.py +4 -1
  72. package/scripts/build-bridge.sh +14 -0
  73. package/scripts/check-capability-bundle-reach.ts +135 -2
  74. package/scripts/check-entwurf-bridge-boot.ts +2 -1
  75. package/scripts/check-entwurf-fact-provider.ts +1 -0
  76. package/scripts/check-entwurf-v2-production.ts +2 -0
  77. package/scripts/check-entwurf-v2-surface.ts +7 -0
  78. package/scripts/check-gate-qualification.ts +20 -8
  79. package/scripts/check-herdr-activation.ts +2 -2
  80. package/scripts/check-herdr-placement.ts +1 -0
  81. package/scripts/check-hook-launch-topology.ts +6 -15
  82. package/scripts/check-install-container.sh +2 -2
  83. package/scripts/check-meta-doctor-oracle.sh +27 -1
  84. package/scripts/check-meta-facts.ts +8 -3
  85. package/scripts/check-meta-hook-session-switch.ts +230 -13
  86. package/scripts/check-meta-identity-consumers.ts +1 -0
  87. package/scripts/check-meta-listing.ts +1 -0
  88. package/scripts/check-meta-manifest-schema.py +1 -1
  89. package/scripts/check-meta-session.ts +31 -0
  90. package/scripts/check-meta-v3-record.ts +55 -1
  91. package/scripts/check-mux-resume-call.ts +35 -9
  92. package/scripts/check-peer-facts.ts +1 -0
  93. package/scripts/check-pi-durable-contact.ts +3 -1
  94. package/scripts/check-pi-durable-send.ts +4 -2
  95. package/scripts/check-pi-mcp-bridge.ts +1 -0
  96. package/scripts/check-remote-pair-installed.ts +242 -0
  97. package/scripts/check-resume-launch-identity.ts +62 -23
  98. package/scripts/lib/meta-hook-bundle.ts +72 -0
  99. package/scripts/lib/remote-pair-installed-audit.ts +25 -0
  100. package/scripts/meta-bridge-claude-floor.sh +13 -9
  101. package/scripts/meta-bridge-doctor.sh +17 -0
  102. package/scripts/meta-bridge-install.sh +6 -0
  103. package/scripts/meta-facts.ts +1 -1
  104. package/scripts/mutants/bridge-boot-resume.json +5 -2
  105. package/scripts/mutants/cc-retire-pidfd.json +129 -0
  106. package/scripts/mutants/cc-retire-request.json +171 -0
  107. package/scripts/mutants/control-retire.json +105 -0
  108. package/scripts/mutants/control-send-receipt.json +2 -2
  109. package/scripts/mutants/copilot-receive.json +2 -6
  110. package/scripts/mutants/entwurf-v2-retire.json +476 -0
  111. package/scripts/mutants/fresh-call-dispatch.json +37 -0
  112. package/scripts/mutants/meta-hook-session-switch.json +88 -0
  113. package/scripts/mutants/meta-identity.json +57 -0
  114. package/scripts/mutants/mux-fresh-call.json +1 -1
  115. package/scripts/mutants/mux-resume-call.json +40 -4
  116. package/scripts/mutants/pi-durable.json +79 -0
  117. package/scripts/mutants/pi-retire-request.json +72 -0
  118. package/scripts/mutants/remote-pair-display.json +53 -0
  119. package/scripts/mutants/remote-pair-installed-audit.json +28 -0
  120. package/scripts/mutants/remote-pair-receive.json +126 -0
  121. package/scripts/mutants/remote-pair-roundtrip.json +83 -0
  122. package/scripts/mutants/remote-pair-send.json +194 -0
  123. package/scripts/mutants/remote-pair-transport.json +66 -0
  124. package/scripts/mutants/resume-launch-identity.json +35 -8
  125. package/scripts/mutants/retirement-event.json +195 -0
  126. package/scripts/mutants/v2-surface.json +16 -0
  127. package/scripts/mutants/v2-visible-resume.json +283 -13
  128. package/scripts/probe-bridge-command.ts +1 -0
  129. package/scripts/remote-pair-receive.ts +73 -0
  130. package/scripts/smoke-agy-install-state.sh +2 -2
  131. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  132. package/scripts/smoke-herdr-raw-install-live.sh +5 -4
  133. package/scripts/smoke-meta-install-state.sh +4 -4
  134. package/scripts/smoke-mux-lifecycle-live.ts +10 -10
  135. package/scripts/smoke-omp-mcp-state.sh +1 -1
  136. package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
  137. package/scripts/smoke-pi-provider-state.sh +3 -3
@@ -0,0 +1,211 @@
1
+ /**
2
+ * retirement-event — the #106 B machine-readable retirement record's APPEND leaf (#147).
3
+ *
4
+ * One target-gid-keyed append-only sidecar per citizen: `<retirementsDir>/<targetGardenId>.jsonl`,
5
+ * one closed v1 JSON line per event. The event body names both ends explicitly
6
+ * (`actorGardenId` / `targetGardenId`) — the filename is never the only carrier.
7
+ *
8
+ * What this leaf is NOT:
9
+ * - not an identity field, liveness fact, dormancy proof, permission or grant. Nothing that
10
+ * decides dispatch, facts, peers or resume reads it; there is no production reader at all.
11
+ * - not a receipt framework. It appends exactly the event it is handed; minting `eventId`/`at`,
12
+ * mapping a dispatch outcome to a summary and the requested/outcome ordering belong to the
13
+ * caller's join.
14
+ * - not a retention, GC or scanning surface. Absence of a line is `unknown`, never "no retire".
15
+ *
16
+ * Path: the directory is an explicit argument. The composition computes it ONCE from its own
17
+ * fixed sessions directory with `retirementsDirBesideSessions`; this module reads no environment
18
+ * and resolves no home directory.
19
+ *
20
+ * IO boundary (Linux-certified, same stance as `readStoreRecordFile` in meta-session.ts):
21
+ * - the directory is created 0700 if absent (parent must exist); an existing entry must be a
22
+ * real directory, never a symlink.
23
+ * - the file is opened O_WRONLY|O_APPEND|O_CREAT|O_NOFOLLOW|O_NONBLOCK, 0600 on create. A final
24
+ * symlink fails (ELOOP), a directory fails (EISDIR), a FIFO never blocks (ENXIO without a
25
+ * reader; with one, the fstat check below refuses it).
26
+ * - the OPENED fd must be a regular file with exactly one link (a hardlink is not appended to).
27
+ * - the whole line is ONE writeSync; a short write is an error. A short write can leave a
28
+ * partial tail line in the file — that is reported, never hidden or repaired here.
29
+ * - fsyncSync, then close. fsync is a flush request to this file, not a power-loss or
30
+ * multi-file transaction guarantee.
31
+ * Residual, outside this leaf: ancestors of the directory are not checked; an existing file's
32
+ * mode is not changed; a same-user process that can rename entries in the directory can swap the
33
+ * name after open (the write still lands on the inode that was checked). The threat model is the
34
+ * same-user store every other meta sidecar shares.
35
+ *
36
+ * Errors: an invalid event or a refused entry throws `RetirementEventError`; a native fs error
37
+ * (ELOOP, EISDIR, ENXIO, EIO, …) is thrown as it is. Either way the append fails ONCE — no retry.
38
+ */
39
+ import * as fs from "node:fs";
40
+ import * as path from "node:path";
41
+ import { isValidSessionId } from "./session-id.js";
42
+ export const RETIREMENT_EVENT_VERSION = 1;
43
+ export const RETIREMENTS_DIR_NAME = "meta-retirements";
44
+ export const RETIREMENT_EVENT_SUFFIX = ".jsonl";
45
+ /**
46
+ * Which outcome statuses each backend can truthfully report: Pi's request boundaries
47
+ * (`shutdown-requested` is a request ACK, never an exit) and the Claude Code adapter's outcomes
48
+ * (`exited` is that fd-pinned process only). A cross-domain pair — Pi `exited`, Claude Code
49
+ * `shutdown-requested` — is refused, so it can never become a durable line. A dispatch-level
50
+ * unknown is `unknown` on either side.
51
+ */
52
+ export const RETIREMENT_OUTCOME_STATUSES_BY_BACKEND = {
53
+ pi: ["shutdown-requested", "refused", "unknown"],
54
+ "claude-code": ["exited", "refused", "not-signalled", "unknown"],
55
+ };
56
+ export class RetirementEventError extends Error {
57
+ constructor(message) {
58
+ super(message);
59
+ this.name = "RetirementEventError";
60
+ }
61
+ }
62
+ const EVENT_ID_RE = /^[0-9a-f]{32}$/;
63
+ /** The shape of `Date#toISOString()` output; calendar validity is the round-trip below. */
64
+ const AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
65
+ const REASON_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
66
+ const BASE_KEYS = ["v", "kind", "eventId", "at", "actorGardenId", "targetGardenId", "targetBackend"];
67
+ const OUTCOME_KEYS = ["status", "reason"];
68
+ /**
69
+ * Read a plain object's own DATA properties exactly once. A foreign prototype (and with it an
70
+ * inherited `toJSON`), an accessor or a symbol key refuses, so what is validated is what is
71
+ * written: serialization only ever sees the fresh object built from these values.
72
+ */
73
+ function snapshotPlain(value, what) {
74
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
75
+ throw new RetirementEventError(`${what} must be an object`);
76
+ }
77
+ const proto = Object.getPrototypeOf(value);
78
+ if (proto !== Object.prototype && proto !== null)
79
+ throw new RetirementEventError(`${what} must be a plain object`);
80
+ const descriptors = Object.getOwnPropertyDescriptors(value);
81
+ const fields = new Map();
82
+ for (const key of Reflect.ownKeys(descriptors)) {
83
+ if (typeof key !== "string")
84
+ throw new RetirementEventError(`${what} must not carry symbol keys`);
85
+ const descriptor = descriptors[key];
86
+ if (!("value" in descriptor))
87
+ throw new RetirementEventError(`${what}.${key} must be a data property`);
88
+ fields.set(key, descriptor.value);
89
+ }
90
+ return fields;
91
+ }
92
+ function requireExactKeys(fields, keys, what) {
93
+ const extra = [...fields.keys()].filter((key) => !keys.includes(key));
94
+ const missing = keys.filter((key) => !fields.has(key));
95
+ if (extra.length > 0 || missing.length > 0) {
96
+ throw new RetirementEventError(`${what} keyset is closed: missing [${missing.join(", ")}], not allowed [${extra.join(", ")}]`);
97
+ }
98
+ }
99
+ /** Exactly `Date#toISOString()` of a real instant: 2026-02-30 or 24:00 does not round-trip. */
100
+ function isIsoInstant(value) {
101
+ if (typeof value !== "string" || !AT_RE.test(value))
102
+ return false;
103
+ const ms = Date.parse(value);
104
+ return !Number.isNaN(ms) && new Date(ms).toISOString() === value;
105
+ }
106
+ /**
107
+ * Validate ONE snapshot of the event and return its exact on-disk bytes (canonical key order, one
108
+ * UTF-8 line) together with the target id taken from that same snapshot. The append routes the
109
+ * file by this id, never by re-reading the caller's object.
110
+ */
111
+ function validateRetirementEvent(event) {
112
+ const f = snapshotPlain(event, "retirement event");
113
+ const kind = f.get("kind");
114
+ if (kind !== "retirement-requested" && kind !== "retirement-outcome") {
115
+ throw new RetirementEventError(`unknown retirement event kind: ${String(kind)}`);
116
+ }
117
+ requireExactKeys(f, kind === "retirement-outcome" ? [...BASE_KEYS, "outcome"] : BASE_KEYS, `${kind} event`);
118
+ const v = f.get("v");
119
+ const eventId = f.get("eventId");
120
+ const at = f.get("at");
121
+ const actorGardenId = f.get("actorGardenId");
122
+ const targetGardenId = f.get("targetGardenId");
123
+ const targetBackend = f.get("targetBackend");
124
+ if (v !== RETIREMENT_EVENT_VERSION)
125
+ throw new RetirementEventError(`unsupported event version: ${String(v)}`);
126
+ if (typeof eventId !== "string" || !EVENT_ID_RE.test(eventId)) {
127
+ throw new RetirementEventError("eventId must be 32 lowercase hex characters");
128
+ }
129
+ if (!isIsoInstant(at))
130
+ throw new RetirementEventError("at must be a valid Date#toISOString() instant");
131
+ if (!isValidSessionId(actorGardenId))
132
+ throw new RetirementEventError("actorGardenId is not a garden id");
133
+ if (!isValidSessionId(targetGardenId))
134
+ throw new RetirementEventError("targetGardenId is not a garden id");
135
+ if (targetBackend !== "pi" && targetBackend !== "claude-code") {
136
+ throw new RetirementEventError(`unsupported targetBackend: ${String(targetBackend)}`);
137
+ }
138
+ const base = { v, kind, eventId, at, actorGardenId, targetGardenId, targetBackend };
139
+ const line = (body) => ({ bytes: Buffer.from(`${JSON.stringify(body)}\n`, "utf8"), targetGardenId });
140
+ if (kind === "retirement-requested")
141
+ return line(base);
142
+ const o = snapshotPlain(f.get("outcome"), "outcome summary");
143
+ requireExactKeys(o, OUTCOME_KEYS, "outcome summary");
144
+ const status = o.get("status");
145
+ const reason = o.get("reason");
146
+ const allowed = RETIREMENT_OUTCOME_STATUSES_BY_BACKEND[targetBackend];
147
+ if (!allowed.includes(status)) {
148
+ throw new RetirementEventError(`outcome status ${String(status)} is not a ${targetBackend} outcome`);
149
+ }
150
+ if (reason !== null && (typeof reason !== "string" || !REASON_RE.test(reason))) {
151
+ throw new RetirementEventError("outcome reason must be null or a short lowercase token");
152
+ }
153
+ return line({ ...base, outcome: { status, reason } });
154
+ }
155
+ /** Validate one event and return its exact on-disk bytes: canonical key order, one UTF-8 line. */
156
+ export function serializeRetirementEvent(event) {
157
+ return validateRetirementEvent(event).bytes;
158
+ }
159
+ /** Pure path: the sidecar directory beside a FIXED sessions directory. */
160
+ export function retirementsDirBesideSessions(sessionsDir) {
161
+ if (!path.isAbsolute(sessionsDir))
162
+ throw new RetirementEventError("sessionsDir must be an absolute path");
163
+ return path.join(path.dirname(path.resolve(sessionsDir)), RETIREMENTS_DIR_NAME);
164
+ }
165
+ export function retirementEventFile(retirementsDir, targetGardenId) {
166
+ if (!path.isAbsolute(retirementsDir))
167
+ throw new RetirementEventError("retirementsDir must be an absolute path");
168
+ if (!isValidSessionId(targetGardenId))
169
+ throw new RetirementEventError("targetGardenId is not a garden id");
170
+ return path.join(retirementsDir, `${targetGardenId}${RETIREMENT_EVENT_SUFFIX}`);
171
+ }
172
+ function ensureRealDirectory(dir) {
173
+ try {
174
+ fs.mkdirSync(dir, { mode: 0o700 });
175
+ }
176
+ catch (err) {
177
+ if (err.code !== "EEXIST")
178
+ throw err;
179
+ }
180
+ const st = fs.lstatSync(dir);
181
+ if (st.isSymbolicLink() || !st.isDirectory()) {
182
+ throw new RetirementEventError(`${path.basename(dir)} is not a real directory; refusing to append`);
183
+ }
184
+ }
185
+ /** Append ONE validated event line to the target's sidecar. Throws once on any failure. */
186
+ export function appendRetirementEvent(retirementsDir, event) {
187
+ const { bytes, targetGardenId } = validateRetirementEvent(event);
188
+ const file = retirementEventFile(retirementsDir, targetGardenId);
189
+ const { O_WRONLY, O_APPEND, O_CREAT, O_NOFOLLOW, O_NONBLOCK } = fs.constants;
190
+ if (!O_NOFOLLOW || !O_NONBLOCK) {
191
+ throw new RetirementEventError("this platform exposes no O_NOFOLLOW/O_NONBLOCK, so the append cannot refuse a symlink or FIFO");
192
+ }
193
+ ensureRealDirectory(retirementsDir);
194
+ const fd = fs.openSync(file, O_WRONLY | O_APPEND | O_CREAT | O_NOFOLLOW | O_NONBLOCK, 0o600);
195
+ try {
196
+ const st = fs.fstatSync(fd);
197
+ if (!st.isFile())
198
+ throw new RetirementEventError(`${path.basename(file)} is not a regular file`);
199
+ if (st.nlink !== 1)
200
+ throw new RetirementEventError(`${path.basename(file)} has ${st.nlink} links; refusing`);
201
+ const written = fs.writeSync(fd, bytes, 0, bytes.length);
202
+ if (written !== bytes.length) {
203
+ throw new RetirementEventError(`short write: ${written} of ${bytes.length} bytes; the file may now end in a partial line`);
204
+ }
205
+ fs.fsyncSync(fd);
206
+ }
207
+ finally {
208
+ fs.closeSync(fd);
209
+ }
210
+ return { bytes: bytes.length };
211
+ }
@@ -53,13 +53,26 @@
53
53
  * closure (build-bridge → dist), because Node REFUSES strip-types on a `.ts` below
54
54
  * node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). The installer picks
55
55
  * the artifact by layout (0.12.5), mirroring start.sh / store-doctor.
56
- * Imports `./lib/meta-session.ts` (+ `./lib/session-id.js`); `rewriteRelativeImportExtensions`
57
- * rewrites that to `./lib/meta-session.js` in the emitted closure. The installer
58
- * copies the lib dir alongside this file so `${CLAUDE_PLUGIN_ROOT}` self-locates it.
56
+ * Imports `./lib/meta-session.ts` and `./lib/entwurf-v2-lock.ts` (+ `./lib/session-id.js`);
57
+ * `rewriteRelativeImportExtensions` rewrites those to `.js` in the emitted closure. The
58
+ * installer copies exactly those lib files alongside this file so `${CLAUDE_PLUGIN_ROOT}`
59
+ * self-locates them.
60
+ *
61
+ * RECORD-ARM LOCK (#147 L2 B1). Every write this hook makes to an EXISTING record happens
62
+ * under a per-gid lock (`defaultMetaRecordArmLocksDir`, the shared `acquireLock` primitive in
63
+ * its own directory — never the v2 dispatch lock dir, which visible resume holds across its
64
+ * observation). The garden id is learned by a READ-ONLY certified lookup; a record found
65
+ * absent is published create-only. On an arm-capable event the lock is held from the upsert
66
+ * that commits a fresh `attachIncarnation` through the receiver marker that copies it, so two
67
+ * normal arms (or an arm and a UserPromptSubmit) always finish as a matching record/marker
68
+ * pair. An arm that cannot take the lock within its bound writes NOTHING and logs ERROR: that
69
+ * attach is not committed, and a live native process may then exist that no record names — a
70
+ * boundary, not a refusal. A UserPromptSubmit that finds the lock busy skips its backfill.
59
71
  */
60
72
  import * as fs from "node:fs";
61
73
  import * as path from "node:path";
62
- import { defaultMetaMailboxDir, defaultMetaSessionsDir, isPlausibleOwnerPid, readMetaSenderMarker, removeMetaReceiverMarker, upsertMetaSession, writeMetaReceiverMarker, writeMetaSenderMarker, } from "./lib/meta-session.js";
74
+ import { acquireLock, releaseLock } from "./lib/entwurf-v2-lock.js";
75
+ import { defaultMetaMailboxDir, defaultMetaRecordArmLocksDir, defaultMetaSessionsDir, findCertifiedIdentityByNativeSessionId, isPlausibleOwnerPid, mintAttachIncarnation, readMetaSenderMarker, removeMetaReceiverMarker, UpsertActionMismatchError, upsertMetaSession, writeMetaReceiverMarker, writeMetaSenderMarker, } from "./lib/meta-session.js";
63
76
  function logLine(level, message) {
64
77
  try {
65
78
  // dirname(meta-sessions) == the pi agent dir — no extra resolver export needed.
@@ -71,8 +84,59 @@ function logLine(level, message) {
71
84
  /* logging is best-effort; a broken log must not break the session */
72
85
  }
73
86
  }
87
+ /** The record-arm lock this run holds, released on every exit path (see {@link emit}). */
88
+ let heldArmLock = null;
89
+ /** Arm-capable events wait this long for the record-arm lock; the critical section is a few file writes. */
90
+ const ARM_LOCK_WAIT_MS = 2_000;
91
+ const ARM_LOCK_POLL_MS = 25;
92
+ function sleepSync(ms) {
93
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
94
+ }
95
+ /**
96
+ * Take the record-arm lock for `gardenId`: ONE attempt, or (arm-capable events) polled up to
97
+ * {@link ARM_LOCK_WAIT_MS}. A throw from the primitive (its claim could not be written) is
98
+ * the same answer as busy — the caller writes nothing.
99
+ */
100
+ function takeArmLock(gardenId, wait) {
101
+ const dir = defaultMetaRecordArmLocksDir();
102
+ const deadline = Date.now() + (wait ? ARM_LOCK_WAIT_MS : 0);
103
+ for (;;) {
104
+ let detail;
105
+ try {
106
+ const acquired = acquireLock(gardenId, { dir });
107
+ if (acquired.ok) {
108
+ heldArmLock = acquired.claim;
109
+ return { ok: true };
110
+ }
111
+ detail = acquired.conflict.detail;
112
+ }
113
+ catch (err) {
114
+ detail = err instanceof Error ? err.message : String(err);
115
+ }
116
+ if (Date.now() >= deadline)
117
+ return { ok: false, detail };
118
+ sleepSync(ARM_LOCK_POLL_MS);
119
+ }
120
+ }
121
+ function releaseArmLock() {
122
+ const claim = heldArmLock;
123
+ if (claim === null)
124
+ return;
125
+ heldArmLock = null;
126
+ try {
127
+ const outcome = releaseLock(claim, { dir: defaultMetaRecordArmLocksDir() });
128
+ if (outcome !== "released")
129
+ logLine("WARN", `record-arm lock release for ${claim.gardenId}: ${outcome}`);
130
+ }
131
+ catch (err) {
132
+ // The holder pid is this short-lived hook, so a lock it failed to unlink is reclaimed
133
+ // by the next acquirer once this process is gone (same host + ESRCH).
134
+ logLine("WARN", `record-arm lock release failed for ${claim.gardenId}: ${err instanceof Error ? err.message : String(err)}`);
135
+ }
136
+ }
74
137
  /** Emit a hook response on stdout and exit 0. `{}` means "did nothing, do not block startup". */
75
138
  function emit(payload) {
139
+ releaseArmLock();
76
140
  process.stdout.write(`${JSON.stringify(payload)}\n`);
77
141
  process.exit(0);
78
142
  }
@@ -149,6 +213,53 @@ function resolveMetaHookOwnerPid() {
149
213
  return null;
150
214
  return ownerPid;
151
215
  }
216
+ /**
217
+ * Commit this event's record write and return the garden id (#147 L2 B1). The garden id to
218
+ * lock is learned READ ONLY; a record found absent is published create-only (a create needs no
219
+ * lock: its exclusive publish cannot replace anything, and a concurrent publisher turns the
220
+ * create into a refusal here, never into an unlocked attach). Every write to an EXISTING
221
+ * record then happens under the record-arm lock, which the caller keeps through the receiver
222
+ * marker write (released by {@link emit}). An arm-capable event mints its binding in that same
223
+ * atomic upsert. Lock busy: an arm writes nothing and logs ERROR (admission failure — not a
224
+ * committed attach, not an armed watch); any other event skips its backfill with a WARN.
225
+ * Throws on a store defect, exactly as the upsert does.
226
+ */
227
+ function commitRecord(input, armsWatch, logContext) {
228
+ let existing = findCertifiedIdentityByNativeSessionId(input.nativeSessionId);
229
+ if (existing === null) {
230
+ try {
231
+ const created = upsertMetaSession({ input, expectAction: "create" });
232
+ logLine("INFO", `create record ${path.basename(created.path)} (${logContext})`);
233
+ // Nothing to bind and nothing older to preserve: the create is the whole write.
234
+ if (!armsWatch)
235
+ return { gardenId: created.record.gardenId, attachIncarnation: undefined };
236
+ existing = created.record;
237
+ }
238
+ catch (err) {
239
+ if (!(err instanceof UpsertActionMismatchError))
240
+ throw err;
241
+ existing = findCertifiedIdentityByNativeSessionId(input.nativeSessionId);
242
+ if (existing === null)
243
+ throw err;
244
+ }
245
+ }
246
+ const locked = takeArmLock(existing.gardenId, armsWatch);
247
+ if (!locked.ok) {
248
+ if (armsWatch) {
249
+ // Admission failure: NOTHING is written, so the store keeps whatever pair it had. This
250
+ // attach is not committed, and a live process may now hold this session unseen.
251
+ logLine("ERROR", `record-arm lock busy — attach NOT committed, watch not armed (garden=${existing.gardenId}, ${logContext}): ${locked.detail}`);
252
+ }
253
+ else {
254
+ logLine("WARN", `record-arm lock busy — backfill skipped (garden=${existing.gardenId}, ${logContext}): ${locked.detail}`);
255
+ }
256
+ emit({});
257
+ }
258
+ const attachIncarnation = armsWatch ? mintAttachIncarnation() : undefined;
259
+ const result = upsertMetaSession({ input: { ...input, attachIncarnation }, expectAction: "attach" });
260
+ logLine("INFO", `${result.action} record ${path.basename(result.path)} (${logContext})`);
261
+ return { gardenId: result.record.gardenId, attachIncarnation };
262
+ }
152
263
  function main() {
153
264
  let raw;
154
265
  try {
@@ -200,13 +311,12 @@ function main() {
200
311
  logLine(degradedLevel, `degraded envelope (event=${eventName}, session_id=${sessionId ? "set" : "MISSING"}, transcript_path=${transcriptPath ? "set" : "MISSING"})`);
201
312
  emit({});
202
313
  }
314
+ // Arm-capable events commit a fresh binding; every other event preserves it.
315
+ const armsWatch = armProvenanceFor(eventName) !== null;
203
316
  let gardenId;
317
+ let attachIncarnation;
204
318
  try {
205
- const result = upsertMetaSession({
206
- input: { backend: "claude-code", nativeSessionId: sessionId, transcriptPath, cwd, model },
207
- });
208
- gardenId = result.record.gardenId;
209
- logLine("INFO", `${result.action} record ${path.basename(result.path)} (event=${eventName}, source=${source}, native=${sessionId})`);
319
+ ({ gardenId, attachIncarnation } = commitRecord({ backend: "claude-code", nativeSessionId: sessionId, transcriptPath, cwd, model }, armsWatch, `event=${eventName}, source=${source}, native=${sessionId}`));
210
320
  }
211
321
  catch (err) {
212
322
  // Best-effort: a broken record store must surface via the doctor, not by
@@ -318,11 +428,15 @@ function main() {
318
428
  nativeSessionId: sessionId,
319
429
  ownerPid,
320
430
  armProvenance,
431
+ attachIncarnation,
321
432
  });
322
433
  logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName} source=${source}`);
323
434
  }
324
435
  catch (err) {
325
- logLine("WARN", `receiver marker write failed (event=${eventName}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`);
436
+ // ERROR, after `INFO armed watch` so the hook-log doctor does not read a later arm as
437
+ // recovery: the record already carries this arm's binding, the marker does not, so the
438
+ // garden is undeliverable AND a later resume refuses it as unbound.
439
+ logLine("ERROR", `receiver marker write failed — record binding committed, marker not (event=${eventName}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`);
326
440
  }
327
441
  }
328
442
  else {
@@ -98,6 +98,13 @@
98
98
  * operator host started outside tmux gets the bridge's named `no-tmux-context` refusal. Exposing the
99
99
  * tool is not a caller LIVE proof.
100
100
  *
101
+ * CALLER RESUME (the seventh tool, #147 L1). `entwurf_resume_call` lets this durable citizen reopen a
102
+ * DORMANT pi or Claude Code citizen under its own garden id (#147 L2), under the bridge's own
103
+ * single-`target` schema (pinned below). The bridge decides everything else from the target's record,
104
+ * exactly as for any other caller: other backends have no same-id resume, a live target is refused,
105
+ * and the window opens in the tmux session this host was launched in. Exposing the tool names no new authority and is not a caller LIVE
106
+ * proof; it never resumes this durable host itself.
107
+ *
101
108
  * NOT HERE: crash-safe receive. `entwurf_inbox_read` archives a body before the durable tool
102
109
  * result commits, so a crash between the two loses that body to the model; recovery settles the
103
110
  * call as interrupted and no doorbell rings again. A drain that fails part-way has archived the
@@ -135,12 +142,14 @@ export const PI_DURABLE_TOOL_NAMES = [
135
142
  "entwurf_inbox_read",
136
143
  "entwurf_callback",
137
144
  "entwurf_fresh_call",
145
+ "entwurf_resume_call",
146
+ "entwurf_retire_call",
138
147
  ];
139
148
  const DRAFT_07 = "http://json-schema.org/draft-07/schema#";
140
149
  /**
141
150
  * The bridge's `tools/list` inputSchemas for the exposed tools (`mcp/entwurf-bridge/src/index.ts`
142
151
  * `entwurf_v2` 456-507, `entwurf_self`, `entwurf_peers`, `entwurf_inbox_read`, `entwurf_callback`,
143
- * `entwurf_fresh_call`),
152
+ * `entwurf_fresh_call`, `entwurf_resume_call`, `entwurf_retire_call`),
144
153
  * as the bridge serialises them.
145
154
  * `attach` compares the live list against these and refuses on any difference.
146
155
  */
@@ -205,7 +214,7 @@ export const PI_DURABLE_TOOL_SCHEMAS = {
205
214
  minLength: 1,
206
215
  maxLength: 200,
207
216
  pattern: "^[A-Za-z0-9][A-Za-z0-9._/:\\[\\]-]*$",
208
- description: "Required runtime model: canonical provider/model for pi, provider/<exact catalog model id> for pi-durable, a Claude Code model id/alias, or a Copilot/OMP/Codex model name.",
217
+ description: "Required runtime model: canonical provider/model for pi (a bare alias is refused), provider/<exact catalog model id> for pi-durable, a Claude Code model id/alias, or a Copilot/OMP/Codex model name. Neither pi nor pi-durable may name the openrouter provider.",
209
218
  },
210
219
  task: {
211
220
  type: "string",
@@ -232,6 +241,32 @@ export const PI_DURABLE_TOOL_SCHEMAS = {
232
241
  required: ["backend", "model", "task"],
233
242
  $schema: DRAFT_07,
234
243
  },
244
+ entwurf_resume_call: {
245
+ type: "object",
246
+ properties: {
247
+ target: {
248
+ type: "string",
249
+ minLength: 1,
250
+ pattern: "^\\d{8}T\\d{6}-[0-9a-f]{6}$",
251
+ description: "Garden id of the DORMANT pi or Claude Code citizen to reopen (discover with entwurf_peers).",
252
+ },
253
+ },
254
+ required: ["target"],
255
+ $schema: DRAFT_07,
256
+ },
257
+ entwurf_retire_call: {
258
+ type: "object",
259
+ properties: {
260
+ target: {
261
+ type: "string",
262
+ minLength: 1,
263
+ pattern: "^\\d{8}T\\d{6}-[0-9a-f]{6}$",
264
+ description: "Exact garden id of the Pi or Claude Code target the human operator explicitly requested to retire.",
265
+ },
266
+ },
267
+ required: ["target"],
268
+ $schema: DRAFT_07,
269
+ },
235
270
  };
236
271
  /** Key-order-insensitive JSON, for comparing a schema copy against the bridge's own. */
237
272
  function canonicalJson(value) {
@@ -438,6 +473,22 @@ const TOOL_DESCRIPTIONS = {
438
473
  "(backend, model and task required; optional cwd and placement). Returns the bridge's LAUNCH receipt or its " +
439
474
  "named refusal — not a started runtime, a delivered task or a reply; the sibling's callback names it. Opening " +
440
475
  "beside this host needs the tmux pane this host itself was launched in.",
476
+ entwurf_resume_call: "Reopen ONE dormant pi or Claude Code citizen under its own garden id in a visible tmux window through the " +
477
+ "entwurf-bridge (target required; the record supplies the session and cwd — no task, no prompt, no turn). " +
478
+ "Returns the bridge's LAUNCH and OBSERVATION receipts or its named refusal: a live target, another backend, " +
479
+ "or a Claude Code citizen not proven dormant is refused by name, and opening the window needs the tmux " +
480
+ "session this host itself was launched in.",
481
+ entwurf_retire_call: "Only when the human operator explicitly requested this particular target/work: make ONE retirement attempt for that request " +
482
+ "through the bridge (target only, Pi or Claude Code; self retirement refuses). Development work, a role or " +
483
+ "a record-backed actor is NOT a grant. Use host approval where provided/configured; " +
484
+ "Pi and pi-durable have no built-in per-call approval prompt by default. An explicit operator request and " +
485
+ "agent operating policy are required; attribution and MCP annotations are hints, not grants or proof of approval. " +
486
+ "No autonomous cleanup, timers or automatic " +
487
+ "retry of unknown, cancelled or lost results. Do not manually /resume or /clear the target during the request. " +
488
+ "Pi shutdown-requested is only an ACK, not process exit, flush or model silence. Claude Code exited is that " +
489
+ "fd-pinned process only, not conversation end, children or flush. The JSON receipt keeps outcome, record and " +
490
+ "release separate; receipt return does not mean exit, and cancellation does not undo an action. Invoke once " +
491
+ "per explicitly named target, never infer a roster. This contact does not turn hints into authorization.",
441
492
  };
442
493
  /** Announce, never push: the notice names the garden and the tool, never the body. */
443
494
  function doorbellNotice(gardenId, unread) {
@@ -15,7 +15,7 @@
15
15
  * {
16
16
  * "schemaVersion": 1, // of THIS projection, not of the records
17
17
  * "storeDir": "/abs/path",
18
- * "citizens": [ <full verbatim strict 9-field v3 records, sorted by gardenId> ],
18
+ * "citizens": [ <full verbatim strict v3 records, sorted by gardenId> ],
19
19
  * "defects": [ { "filename", "message" }, … sorted by filename ]
20
20
  * }
21
21
  *
@@ -42,6 +42,7 @@ export const EXPECTED_TOOLS = [
42
42
  "entwurf_register_native",
43
43
  "entwurf_fresh_call",
44
44
  "entwurf_resume_call",
45
+ "entwurf_retire_call",
45
46
  "entwurf_callback",
46
47
  ];
47
48
  const DEFAULT_TIMEOUT_MS = 10_000;
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * remote-pair-receive — `entwurf remote-pair-receive --pair <absolute file>` (#107 rail 1).
4
+ *
5
+ * The receiving host's entry for one declared remote pair. The sending host runs it over one
6
+ * SSH call and writes one JSON claim to stdin. This entry reads the operator-named pair
7
+ * declaration once, applies the pair scope check, and hands a matching claim to the existing
8
+ * v2 dispatch. The scope and trust model are in pi-extensions/lib/remote-pair-receive.ts.
9
+ *
10
+ * Scope: `--pair` must name THIS host's own opt-in file (`remotePairOptInPath()`), absolute and
11
+ * normalized-equal; any other path — even a valid declaration — is exit 2. That one file is the pair
12
+ * this host accepts and, because it names the sender's host as its route, the declared route back.
13
+ * The sender's declaration carries the path only so the argv shape is explicit; it selects nothing.
14
+ *
15
+ * EXIT CONTRACT (stdout carries a JSON receipt line only where named):
16
+ * 0 dispatched, the dispatch did not report an error — receipt on stdout
17
+ * 4 dispatched, the dispatch reported an error (its text says what is known, e.g. a
18
+ * rejection or `retry-safe: false` after a possible write) — receipt
19
+ * 3 refused by the pair scope check, no dispatch ran — receipt
20
+ * 2 --pair is not this host's opt-in, the opt-in is absent/broken, or the claim is invalid; no
21
+ * dispatch ran — `{kind:"invalid"}` receipt;
22
+ * wrong argv is ALSO 2 but prints usage on stderr only, with no receipt
23
+ * 1 an uncaught dispatch/store throw (crash, don't warn) — no receipt; stderr carries the
24
+ * error. The sending host reads that as indeterminate.
25
+ */
26
+ import { readFileSync } from "node:fs";
27
+ import * as path from "node:path";
28
+ import { runAndRenderEntwurfV2FromSurface } from "../pi-extensions/lib/entwurf-v2-surface.js";
29
+ import { parseRemoteClaim, receiveRemoteClaim, } from "../pi-extensions/lib/remote-pair-receive.js";
30
+ import { readRemotePairOptIn, remotePairOptInPath } from "../pi-extensions/lib/remote-pair-send.js";
31
+ function emit(receipt, code) {
32
+ process.stdout.write(`${JSON.stringify(receipt)}\n`);
33
+ process.exit(code);
34
+ }
35
+ const args = process.argv.slice(2);
36
+ if (args.length !== 2 || args[0] !== "--pair") {
37
+ console.error("usage: entwurf remote-pair-receive --pair <this host's opt-in file> (claim JSON on stdin)");
38
+ process.exit(2);
39
+ }
40
+ const pairFile = args[1];
41
+ let decl;
42
+ let claim;
43
+ try {
44
+ // The scope is THIS host's own opt-in, never a file the sender picks: `--pair` must name it
45
+ // exactly (absolute, normalized), and that one file is read for this receive.
46
+ const own = remotePairOptInPath();
47
+ if (!path.isAbsolute(pairFile) || path.normalize(pairFile) !== own) {
48
+ throw new Error(`--pair must be this host's opt-in file ${own} (got ${pairFile})`);
49
+ }
50
+ const route = readRemotePairOptIn(own);
51
+ if (route === null)
52
+ throw new Error(`this host has no remote pair opt-in (${own} is absent)`);
53
+ decl = { remoteGardenId: route.remoteGardenId, ssh: route.ssh, localGardenId: route.localGardenId };
54
+ claim = parseRemoteClaim(readFileSync(0, "utf8"));
55
+ }
56
+ catch (err) {
57
+ emit({ kind: "invalid", detail: err instanceof Error ? err.message : String(err) }, 2);
58
+ }
59
+ const receipt = await receiveRemoteClaim(claim, decl, {
60
+ dispatch: (params, sender) => runAndRenderEntwurfV2FromSurface(params, { senderProvider: () => sender }),
61
+ // The scope file IS this host's own opt-in, and it names the sender's host as its route: a
62
+ // matching claim always has a declared route back. Declared, not probed.
63
+ replyRoute: "declared",
64
+ });
65
+ emit(receipt, receipt.kind === "rejected" ? 3 : receipt.isError ? 4 : 0);