@junghanacs/entwurf 0.40.0 → 0.41.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 +4 -2
- package/BASELINE.md +55 -2
- package/CHANGELOG.md +562 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +18 -2
- package/FAQ.md +8 -4
- package/README.md +60 -45
- package/VERIFY.md +14 -11
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +6 -2
- package/docs/external-mcp-host.md +8 -6
- package/docs/fresh-cut-policy.md +9 -1
- package/docs/setup-clean-host.md +76 -20
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +81 -19
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/src/index.ts +93 -19
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/mcp/entwurf-bridge/tsconfig.build.json +10 -1
- package/package.json +15 -14
- package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
- package/pi/pi-durable/carrier/tui.js +18 -3
- package/pi/pi-durable/overlay/upstream-pin.json +12 -12
- package/pi-extensions/entwurf-control.ts +55 -20
- package/pi-extensions/lib/acp/backend-adapter.ts +5 -3
- package/pi-extensions/lib/acp/backend.ts +12 -9
- package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/pi-extensions/lib/cc-retire-request.ts +553 -0
- package/pi-extensions/lib/control-retire.ts +195 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
- package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
- package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
- package/pi-extensions/lib/fresh-call-composition.ts +15 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/meta-session.ts +140 -16
- package/pi-extensions/lib/mux-fresh-call.ts +7 -27
- package/pi-extensions/lib/mux-launch.ts +29 -0
- package/pi-extensions/lib/mux-resume-call.ts +33 -12
- package/pi-extensions/lib/pi-retire-request.ts +111 -0
- package/pi-extensions/lib/resume-launch-identity.ts +104 -18
- package/pi-extensions/lib/retirement-event.ts +253 -0
- package/pi-extensions/meta-bridge-hook.ts +142 -13
- package/pi-extensions/meta-bridge-pi-durable.ts +57 -3
- package/run.sh +123 -34
- package/scripts/agy-bridge-config.py +4 -1
- package/scripts/build-bridge.sh +14 -0
- package/scripts/check-capability-bundle-reach.ts +135 -2
- package/scripts/check-entwurf-bridge-boot.ts +2 -1
- package/scripts/check-entwurf-fact-provider.ts +1 -0
- package/scripts/check-entwurf-v2-production.ts +2 -0
- package/scripts/check-entwurf-v2-surface.ts +7 -0
- package/scripts/check-gate-qualification.ts +14 -8
- package/scripts/check-herdr-activation.ts +2 -2
- package/scripts/check-herdr-placement.ts +1 -0
- package/scripts/check-hook-launch-topology.ts +6 -15
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-meta-doctor-oracle.sh +27 -1
- package/scripts/check-meta-facts.ts +8 -3
- package/scripts/check-meta-hook-session-switch.ts +230 -13
- package/scripts/check-meta-identity-consumers.ts +1 -0
- package/scripts/check-meta-listing.ts +1 -0
- package/scripts/check-meta-manifest-schema.py +1 -1
- package/scripts/check-meta-session.ts +31 -0
- package/scripts/check-meta-v3-record.ts +55 -1
- package/scripts/check-mux-resume-call.ts +35 -9
- package/scripts/check-peer-facts.ts +1 -0
- package/scripts/check-pi-durable-contact.ts +3 -1
- package/scripts/check-pi-durable-send.ts +4 -2
- package/scripts/check-pi-mcp-bridge.ts +1 -0
- package/scripts/check-resume-launch-identity.ts +62 -23
- package/scripts/lib/codex-fresh-live-protocol.ts +3 -2
- package/scripts/lib/meta-hook-bundle.ts +72 -0
- package/scripts/meta-bridge-claude-floor.sh +13 -9
- package/scripts/meta-bridge-doctor.sh +17 -0
- package/scripts/meta-bridge-install.sh +6 -0
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/bridge-boot-resume.json +5 -2
- package/scripts/mutants/cc-retire-pidfd.json +129 -0
- package/scripts/mutants/cc-retire-request.json +171 -0
- package/scripts/mutants/control-retire.json +105 -0
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/copilot-receive.json +2 -6
- package/scripts/mutants/entwurf-v2-retire.json +476 -0
- package/scripts/mutants/fresh-call-dispatch.json +37 -0
- package/scripts/mutants/meta-hook-session-switch.json +88 -0
- package/scripts/mutants/meta-identity.json +57 -0
- package/scripts/mutants/mux-fresh-call.json +1 -1
- package/scripts/mutants/mux-resume-call.json +40 -4
- package/scripts/mutants/pack-install.json +4 -4
- package/scripts/mutants/pi-durable.json +139 -0
- package/scripts/mutants/pi-floor.json +5 -5
- package/scripts/mutants/pi-retire-request.json +72 -0
- package/scripts/mutants/resume-launch-identity.json +35 -8
- package/scripts/mutants/retirement-event.json +195 -0
- package/scripts/mutants/v2-surface.json +16 -0
- package/scripts/mutants/v2-visible-resume.json +283 -13
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-codex-fresh-live.ts +2 -1
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +15 -7
- package/scripts/smoke-meta-install-state.sh +4 -4
- package/scripts/smoke-mux-lifecycle-live.ts +10 -10
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
- package/scripts/smoke-pi-provider-state.sh +3 -3
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* entwurf-v2-visible-resume — stand a dormant pi citizen back up in a window the
|
|
3
|
-
* see, under the same garden id, and say only what was actually observed.
|
|
2
|
+
* entwurf-v2-visible-resume — stand a dormant pi or Claude Code citizen back up in a window the
|
|
3
|
+
* operator can see, under the same garden id, and say only what was actually observed.
|
|
4
|
+
*
|
|
5
|
+
* ── Two backends, one shape (#147 L2) ──
|
|
6
|
+
*
|
|
7
|
+
* Both own their transcript and re-attach to their record when the same native session reopens.
|
|
8
|
+
* What differs is the rail each one proves itself on, so the dormancy proof and the observation are
|
|
9
|
+
* per backend and nothing else is:
|
|
10
|
+
*
|
|
11
|
+
* pi dormant = its control socket is dead; back = that socket answers again.
|
|
12
|
+
* claude-code dormant = the LAST VALIDATED owner the Entwurf hook armed for this session is
|
|
13
|
+
* proven gone: the receiver marker names an owner `classifyMarkerOwner` calls DEAD,
|
|
14
|
+
* AND the record re-read after that verdict still carries the same non-null
|
|
15
|
+
* `attachIncarnation` the marker copied (#147 L2 B1) — so no arm-capable attach
|
|
16
|
+
* committed after that owner's. An absent, unreadable, mismatched, live,
|
|
17
|
+
* inconclusive or unbound marker proves nothing and refuses. A process that holds
|
|
18
|
+
* the session without a committed hook attach (hooks disabled, a record-arm lock
|
|
19
|
+
* timeout, a store write failure, a launch after the re-read) is invisible to this
|
|
20
|
+
* proof. Back = a NEW live owner, in the pane this call opened, re-armed the marker
|
|
21
|
+
* for the same session with a fresh binding the current record also carries (same
|
|
22
|
+
* createdAt). The old marker read again is never success.
|
|
23
|
+
*
|
|
24
|
+
* Every other backend is the leaf's named refusal. The argv is a fixed table per backend; the caller
|
|
25
|
+
* supplies only the target.
|
|
4
26
|
*
|
|
5
27
|
* ── Where this sits ──
|
|
6
28
|
*
|
|
@@ -41,14 +63,19 @@
|
|
|
41
63
|
*
|
|
42
64
|
* ── Two receipts, never merged ──
|
|
43
65
|
*
|
|
44
|
-
* `launch` says tmux made a window and was asked to start
|
|
45
|
-
* citizen
|
|
46
|
-
*
|
|
47
|
-
*
|
|
66
|
+
* `launch` says tmux made a window and was asked to start the runtime. `observation` says whether
|
|
67
|
+
* the citizen came back under the same id (pi: its socket; Claude Code: its receiver marker).
|
|
68
|
+
* Neither says the resumed citizen did anything. This verb submits no prompt. For pi a resume ran
|
|
69
|
+
* no turn (measured 2026-08-06, pi only — the transcript stayed byte-identical); for Claude Code
|
|
70
|
+
* the runtime's own activity after resume (its SessionStart hook re-arms the doorbell, and unread
|
|
71
|
+
* mail may then wake a turn) is unmeasured, so this module claims no turn outcome either way.
|
|
72
|
+
* The socket-only description in the observation section above is the pi half; the Claude Code
|
|
73
|
+
* half reads the receiver marker and the record instead.
|
|
48
74
|
*/
|
|
49
75
|
import { ACP_MODEL_NOT_CURATED_HINT, AcpModelNotCuratedError } from "./acp/model-ids.js";
|
|
50
76
|
import { buildResumePiArgs } from "./entwurf-resume-args.js";
|
|
51
77
|
import { ENTWURF_V2_LOCK_DIR, acquireLock as realAcquireLock, releaseLock as realReleaseLock, } from "./entwurf-v2-lock.js";
|
|
78
|
+
import { classifyMarkerOwner, metaReceiverMarkerPath, parseMetaReceiverMarker, probePidExistence, processStartKey, readStoreRecordFile, } from "./meta-session.js";
|
|
52
79
|
import { ResumeBackendUnsupportedError, resolveResumeLaunchIdentity, } from "./resume-launch-identity.js";
|
|
53
80
|
import { isValidSessionId } from "./session-id.js";
|
|
54
81
|
import { CONTROL_SOCKET_DIR, inspectTargetControlSocket, mapInspectionToLiveness, } from "./socket-discovery.js";
|
|
@@ -68,12 +95,60 @@ export function makeVisibleResumeDeps(launch, overrides = {}) {
|
|
|
68
95
|
resolveIdentity: resolveResumeLaunchIdentity,
|
|
69
96
|
inspectSocket: (gid) => inspectTargetControlSocket(gid, CONTROL_SOCKET_DIR),
|
|
70
97
|
probeSocket: (socketPath) => probeSocketLiveness(socketPath),
|
|
98
|
+
inspectReceiver: (gid) => inspectReceiverMarker(gid),
|
|
71
99
|
launch,
|
|
72
100
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
73
101
|
now: () => Date.now(),
|
|
74
102
|
...overrides,
|
|
75
103
|
};
|
|
76
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* The receiver marker under `gardenId`, read RAW (no owner verification inside the reader — the
|
|
107
|
+
* path reader's `null` folds absent, corrupt and dead into one answer, and only "dead" may permit
|
|
108
|
+
* a resume) and judged by `classifyMarkerOwner`, the one place a dead verdict is produced. The
|
|
109
|
+
* bytes come from `readStoreRecordFile` — `O_NOFOLLOW` open, `fstat` kind and read on that one fd
|
|
110
|
+
* (#147 L2 D3) — so a symlink or a non-regular entry is corrupt and never followed, with no
|
|
111
|
+
* window between classifying the entry and reading it. `ENOENT` alone is absent.
|
|
112
|
+
*/
|
|
113
|
+
export function inspectReceiverMarker(gardenId, receiversDir) {
|
|
114
|
+
const markerPath = metaReceiverMarkerPath(gardenId, receiversDir);
|
|
115
|
+
let bytes;
|
|
116
|
+
try {
|
|
117
|
+
bytes = readStoreRecordFile(markerPath);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (err.code === "ENOENT")
|
|
121
|
+
return { kind: "absent" };
|
|
122
|
+
return { kind: "corrupt" };
|
|
123
|
+
}
|
|
124
|
+
let marker;
|
|
125
|
+
try {
|
|
126
|
+
marker = parseMetaReceiverMarker(bytes);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return { kind: "corrupt" };
|
|
130
|
+
}
|
|
131
|
+
if (marker === null)
|
|
132
|
+
return { kind: "corrupt" };
|
|
133
|
+
const owner = classifyMarkerOwner(marker.ownerStartKey, {
|
|
134
|
+
currentStartKey: processStartKey(marker.ownerPid),
|
|
135
|
+
pidExists: probePidExistence(marker.ownerPid),
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
kind: "marker",
|
|
139
|
+
marker: {
|
|
140
|
+
gardenId: marker.gardenId,
|
|
141
|
+
backend: marker.backend,
|
|
142
|
+
nativeSessionId: marker.nativeSessionId,
|
|
143
|
+
ownerPid: marker.ownerPid,
|
|
144
|
+
ownerStartKey: marker.ownerStartKey,
|
|
145
|
+
attachIncarnation: marker.attachIncarnation,
|
|
146
|
+
ownerKind: marker.ownerKind,
|
|
147
|
+
armProvenance: marker.armProvenance,
|
|
148
|
+
},
|
|
149
|
+
owner,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
77
152
|
/**
|
|
78
153
|
* Reopen `targetGardenId` in a visible window.
|
|
79
154
|
*
|
|
@@ -131,7 +206,7 @@ export async function visibleResume(targetGardenId, deps) {
|
|
|
131
206
|
}
|
|
132
207
|
catch (err) {
|
|
133
208
|
// #128: the recorded model is not an exact curated ACP id, so Pi would pick another row
|
|
134
|
-
// by substring. Same shape as `target-
|
|
209
|
+
// by substring. Same shape as `target-backend-unsupported`: a named refusal matched on its FIELD.
|
|
135
210
|
if (err instanceof AcpModelNotCuratedError) {
|
|
136
211
|
return {
|
|
137
212
|
ok: false,
|
|
@@ -143,12 +218,14 @@ export async function visibleResume(targetGardenId, deps) {
|
|
|
143
218
|
return {
|
|
144
219
|
ok: false,
|
|
145
220
|
reason: err.reason,
|
|
146
|
-
detail: `${targetGardenId} is a ${err.backend} citizen; only pi
|
|
147
|
-
`
|
|
221
|
+
detail: `${targetGardenId} is a ${err.backend} citizen; only pi and Claude Code citizens can be reopened ` +
|
|
222
|
+
`under the same garden id`,
|
|
148
223
|
};
|
|
149
224
|
}
|
|
150
225
|
throw err;
|
|
151
226
|
}
|
|
227
|
+
if (identity.backend === "claude-code")
|
|
228
|
+
return await resumeClaudeCode(targetGardenId, identity, deps);
|
|
152
229
|
// Liveness preflight, still under the lock. A live citizen is addressed with
|
|
153
230
|
// `entwurf_v2 fire-and-forget`, not reopened — and refusing here is also what stops two
|
|
154
231
|
// windows existing for one garden id.
|
|
@@ -183,14 +260,14 @@ export async function visibleResume(targetGardenId, deps) {
|
|
|
183
260
|
provider: identity.provider,
|
|
184
261
|
model: identity.model,
|
|
185
262
|
});
|
|
186
|
-
const launched = deps.launch({ cwd: identity.cwd, runtimeArgs });
|
|
263
|
+
const launched = deps.launch({ backend: "pi", cwd: identity.cwd, runtimeArgs });
|
|
187
264
|
if (!launched.ok) {
|
|
188
265
|
return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
|
|
189
266
|
}
|
|
190
267
|
const observation = await observeSameGidSocket(targetGardenId, mapped.socketPath, deps);
|
|
191
268
|
return {
|
|
192
269
|
ok: true,
|
|
193
|
-
launch: { targetGardenId, sessionFile: identity.sessionFile, handle: launched.handle },
|
|
270
|
+
launch: { backend: "pi", targetGardenId, sessionFile: identity.sessionFile, handle: launched.handle },
|
|
194
271
|
observation,
|
|
195
272
|
};
|
|
196
273
|
}
|
|
@@ -198,6 +275,153 @@ export async function visibleResume(targetGardenId, deps) {
|
|
|
198
275
|
release();
|
|
199
276
|
}
|
|
200
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* The Claude Code half, still under the caller's lock. Dormancy needs a DEAD owner verdict on the
|
|
280
|
+
* receiver marker AND the binding: the record re-read AFTER that verdict must still name the same
|
|
281
|
+
* session and createdAt, and carry the same non-null `attachIncarnation` the marker copied. An
|
|
282
|
+
* absent marker (never armed, or retired when its process switched session), an unreadable one,
|
|
283
|
+
* one that names another session, a live owner, an inconclusive one and an unbound one each refuse
|
|
284
|
+
* by name, with no window. The re-read catches every attach whose atomic record write committed
|
|
285
|
+
* before it; a launch or attach that begins after it is not predicted. The launch uses the
|
|
286
|
+
* re-read record (its cwd), never the first read. The marker and the record are never removed or
|
|
287
|
+
* rewritten here — they are evidence, not something this verb may clear.
|
|
288
|
+
*/
|
|
289
|
+
async function resumeClaudeCode(targetGardenId, identity, deps) {
|
|
290
|
+
const before = deps.inspectReceiver(targetGardenId);
|
|
291
|
+
if (before.kind === "absent") {
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
reason: "target-receiver-absent",
|
|
295
|
+
detail: `no receiver marker names ${targetGardenId}, so nothing proves its Claude Code process is gone`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (before.kind === "corrupt") {
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
reason: "target-receiver-corrupt",
|
|
302
|
+
detail: `the receiver marker for ${targetGardenId} is not a readable regular marker, so its owner cannot be judged`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const marker = before.marker;
|
|
306
|
+
if (marker.gardenId !== targetGardenId ||
|
|
307
|
+
marker.backend !== "claude-code" ||
|
|
308
|
+
marker.nativeSessionId !== identity.nativeSessionId) {
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
reason: "target-receiver-mismatch",
|
|
312
|
+
detail: `the receiver marker for ${targetGardenId} names ${marker.backend}/${marker.nativeSessionId}, not the ` +
|
|
313
|
+
`record's claude-code/${identity.nativeSessionId}`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (before.owner === "live") {
|
|
317
|
+
return {
|
|
318
|
+
ok: false,
|
|
319
|
+
reason: "target-live",
|
|
320
|
+
detail: `${targetGardenId}'s Claude Code process (pid ${marker.ownerPid}) is still running — send to it with entwurf_v2 fire-and-forget instead`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (before.owner !== "dead") {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
reason: "target-indeterminate",
|
|
327
|
+
detail: `the receiver marker's owner (pid ${marker.ownerPid}) for ${targetGardenId} could not be proven gone, ` +
|
|
328
|
+
`and resuming a session that is still open would put two processes on one transcript`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
// The binding, read AFTER the dead verdict. A store or record defect here throws, exactly as
|
|
332
|
+
// the first read does: no window exists yet.
|
|
333
|
+
const current = deps.resolveIdentity(targetGardenId);
|
|
334
|
+
if (current.backend !== "claude-code" ||
|
|
335
|
+
current.nativeSessionId !== identity.nativeSessionId ||
|
|
336
|
+
current.createdAt !== identity.createdAt) {
|
|
337
|
+
return {
|
|
338
|
+
ok: false,
|
|
339
|
+
reason: "target-receiver-unbound",
|
|
340
|
+
detail: `the record under ${targetGardenId} changed between the two reads, so the dead marker cannot be bound to it`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (current.attachIncarnation === null || marker.attachIncarnation === null) {
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
reason: "target-receiver-unbound",
|
|
347
|
+
detail: `no arm binding was observed for ${targetGardenId} (record ${current.attachIncarnation === null ? "has none" : "has one"}, ` +
|
|
348
|
+
`marker ${marker.attachIncarnation === null ? "has none" : "has one"}), so the dead owner is not proven to be the last attach`,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
if (current.attachIncarnation !== marker.attachIncarnation) {
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
reason: "target-receiver-unbound",
|
|
355
|
+
detail: `the record of ${targetGardenId} carries a later arm binding than its receiver marker — an attach committed ` +
|
|
356
|
+
`after the dead owner's, and its process is not proven gone`,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const launched = deps.launch({
|
|
360
|
+
backend: "claude-code",
|
|
361
|
+
cwd: current.cwd,
|
|
362
|
+
runtimeArgs: ["--resume", current.nativeSessionId],
|
|
363
|
+
});
|
|
364
|
+
if (!launched.ok) {
|
|
365
|
+
return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
|
|
366
|
+
}
|
|
367
|
+
const observation = await observeReceiverReattached(targetGardenId, current, { ownerPid: marker.ownerPid, ownerStartKey: marker.ownerStartKey, attachIncarnation: current.attachIncarnation }, launched.handle.panePid, deps);
|
|
368
|
+
return {
|
|
369
|
+
ok: true,
|
|
370
|
+
launch: {
|
|
371
|
+
backend: "claude-code",
|
|
372
|
+
targetGardenId,
|
|
373
|
+
nativeSessionId: current.nativeSessionId,
|
|
374
|
+
handle: launched.handle,
|
|
375
|
+
},
|
|
376
|
+
observation,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* ONE bounded operation, the Claude Code twin of `observeSameGidSocket`: wait for a NEW live owner
|
|
381
|
+
* — in the pane this call opened (`panePid`), a join, not an address — to re-arm this garden id's
|
|
382
|
+
* receiver marker for the same native session with a fresh binding the current record carries
|
|
383
|
+
* (same createdAt). The dead marker read again is never success: the owner must differ from the one
|
|
384
|
+
* the dormancy proof judged dead, and the binding from the one it bound. A marker armed by some
|
|
385
|
+
* other process is simply not-yet, and ends as `resume-unobserved`. A record or store defect is not
|
|
386
|
+
* a startup state: it stops the wait at once as `record-unreadable`, with its cause.
|
|
387
|
+
*/
|
|
388
|
+
export async function observeReceiverReattached(targetGardenId, identity, previous, panePid, deps, timeoutMs = RESUME_OBSERVE_TIMEOUT_MS, intervalMs = RESUME_OBSERVE_INTERVAL_MS) {
|
|
389
|
+
const started = deps.now();
|
|
390
|
+
for (;;) {
|
|
391
|
+
const seen = deps.inspectReceiver(targetGardenId);
|
|
392
|
+
if (seen.kind === "marker" &&
|
|
393
|
+
seen.owner === "live" &&
|
|
394
|
+
seen.marker.gardenId === targetGardenId &&
|
|
395
|
+
seen.marker.backend === "claude-code" &&
|
|
396
|
+
seen.marker.nativeSessionId === identity.nativeSessionId &&
|
|
397
|
+
String(seen.marker.ownerPid) === panePid &&
|
|
398
|
+
(seen.marker.ownerPid !== previous.ownerPid || seen.marker.ownerStartKey !== previous.ownerStartKey) &&
|
|
399
|
+
seen.marker.attachIncarnation !== null &&
|
|
400
|
+
seen.marker.attachIncarnation !== previous.attachIncarnation) {
|
|
401
|
+
let current;
|
|
402
|
+
try {
|
|
403
|
+
current = deps.resolveIdentity(targetGardenId);
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
return {
|
|
407
|
+
kind: "record-unreadable",
|
|
408
|
+
cause: err instanceof Error ? err.message : String(err),
|
|
409
|
+
waitedMs: deps.now() - started,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (current.backend === "claude-code" &&
|
|
413
|
+
current.nativeSessionId === identity.nativeSessionId &&
|
|
414
|
+
current.createdAt === identity.createdAt &&
|
|
415
|
+
current.attachIncarnation === seen.marker.attachIncarnation) {
|
|
416
|
+
return { kind: "receiver-armed", ownerPid: seen.marker.ownerPid, waitedMs: deps.now() - started };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (deps.now() - started >= timeoutMs) {
|
|
420
|
+
return { kind: "resume-unobserved", waitedMs: deps.now() - started };
|
|
421
|
+
}
|
|
422
|
+
await deps.sleep(intervalMs);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
201
425
|
/**
|
|
202
426
|
* ONE bounded operation: wait for the SAME garden id's control socket to answer. Alive is the
|
|
203
427
|
* only early exit — an inspection that comes back absent, corrupt or inconclusive during startup
|
|
@@ -219,11 +443,15 @@ export async function observeSameGidSocket(targetGardenId, socketPath, deps, tim
|
|
|
219
443
|
}
|
|
220
444
|
const REJECT_HINT = {
|
|
221
445
|
"target-invalid": "no citizen has that id; discover targets with entwurf_peers",
|
|
222
|
-
"target-
|
|
446
|
+
"target-backend-unsupported": "that citizen's backend has no same-id resume (only pi and Claude Code do) — reach it with entwurf_v2, or open a fresh sibling",
|
|
223
447
|
"acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
|
|
224
448
|
"target-locked": "another resume or dispatch holds this citizen's lock right now",
|
|
225
449
|
"target-live": "that citizen is already running — this verb only reopens dormant ones",
|
|
226
|
-
"target-indeterminate": "the
|
|
450
|
+
"target-indeterminate": "the citizen could not be proven dormant (an inconclusive socket probe or receiver owner), so nothing was started",
|
|
451
|
+
"target-receiver-absent": "a Claude Code citizen is proven dormant only by its receiver marker, and it has none — if its window is closed, reopen it yourself",
|
|
452
|
+
"target-receiver-corrupt": "the citizen's receiver marker cannot be read as a marker, so its owner cannot be judged",
|
|
453
|
+
"target-receiver-mismatch": "the receiver marker under this garden id names another backend or native session than the record — look at the store before anything reopens",
|
|
454
|
+
"target-receiver-unbound": "the dead receiver owner is not bound to the record's last arm (none observed, a later attach, or the record moved), so a Claude Code process may still hold the session — if its window is closed, reopen it yourself",
|
|
227
455
|
"target-address-conflict": "that citizen's control-socket path is corrupt",
|
|
228
456
|
"launch-refused": "tmux was never asked to open a window",
|
|
229
457
|
};
|
|
@@ -236,7 +464,7 @@ const REJECT_HINT = {
|
|
|
236
464
|
* who sees "window opened" and "socket alive" as one sentence has been told the resume worked;
|
|
237
465
|
* a reader who sees them as two facts can tell which one is missing when only one is there.
|
|
238
466
|
*/
|
|
239
|
-
export function renderVisibleResume(result) {
|
|
467
|
+
export function renderVisibleResume(result, attribution = { actor: null }) {
|
|
240
468
|
if (!result.ok) {
|
|
241
469
|
return {
|
|
242
470
|
text: `entwurf_resume_call rejected: ${result.reason} — ${REJECT_HINT[result.reason]}. ` +
|
|
@@ -245,22 +473,57 @@ export function renderVisibleResume(result) {
|
|
|
245
473
|
};
|
|
246
474
|
}
|
|
247
475
|
const h = result.launch.handle;
|
|
248
|
-
|
|
476
|
+
// Who asked is recorded, and it is not authority: the verb decides from the target's record and
|
|
477
|
+
// live rail facts alone, whoever the caller is.
|
|
478
|
+
const actor = attribution.actor === null
|
|
479
|
+
? ` actor: unattributed (the caller has no record-backed garden identity)\n`
|
|
480
|
+
: ` actor: ${attribution.actor} (record-backed caller; attribution, not authority)\n`;
|
|
481
|
+
const window = ` runtime: ${h.runtimePath} (requested start cwd ${h.cwd})\n` +
|
|
482
|
+
` window: ${h.windowId} (index ${h.windowIndex}) in session ${h.sessionId}\n` +
|
|
483
|
+
` pane: ${h.paneId} pid ${h.panePid}\n`;
|
|
484
|
+
if (result.launch.backend === "claude-code") {
|
|
485
|
+
const observation = result.observation;
|
|
486
|
+
return {
|
|
487
|
+
text: `[entwurf resume call ↻]\n` +
|
|
488
|
+
` target: ${result.launch.targetGardenId}\n` +
|
|
489
|
+
` backend: claude-code\n` +
|
|
490
|
+
` session: ${result.launch.nativeSessionId}\n` +
|
|
491
|
+
window +
|
|
492
|
+
actor +
|
|
493
|
+
`\n` +
|
|
494
|
+
`LAUNCH receipt: tmux created that window and was asked to start claude --resume on the session above.\n` +
|
|
495
|
+
(observation.kind === "receiver-armed"
|
|
496
|
+
? `OBSERVATION receipt: receiver-armed — a NEW live Claude Code process in the window this call opened ` +
|
|
497
|
+
`(pid ${observation.ownerPid}) re-armed the receiver marker of ${result.launch.targetGardenId} for the ` +
|
|
498
|
+
`same session after ${observation.waitedMs}ms, with a fresh arm binding the record also carries ` +
|
|
499
|
+
`(same createdAt). entwurf_v2 fire-and-forget now reaches its mailbox. This call submitted no ` +
|
|
500
|
+
`prompt; the runtime's own activity after resume (e.g. a doorbell on unread mail) is unmeasured.`
|
|
501
|
+
: observation.kind === "record-unreadable"
|
|
502
|
+
? `OBSERVATION receipt: record-unreadable — the record of ${result.launch.targetGardenId} could not be ` +
|
|
503
|
+
`read after ${observation.waitedMs}ms: ${observation.cause}. The window is open and visible; the ` +
|
|
504
|
+
`lock was released, nothing was retried, and nothing was killed.`
|
|
505
|
+
: `OBSERVATION receipt: resume-unobserved — no new live receiver marker from the window this call ` +
|
|
506
|
+
`opened, bound to the record, appeared within ${observation.waitedMs}ms. The window is open and ` +
|
|
507
|
+
`visible; read it directly. The lock was released, nothing was retried, and nothing was killed.`),
|
|
508
|
+
isError: false,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
const observation = result.observation;
|
|
512
|
+
const observed = observation.kind === "socket-alive";
|
|
249
513
|
return {
|
|
250
514
|
text: `[entwurf resume call ↻]\n` +
|
|
251
515
|
` target: ${result.launch.targetGardenId}\n` +
|
|
252
516
|
` session: ${result.launch.sessionFile}\n` +
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
` pane: ${h.paneId} pid ${h.panePid}\n` +
|
|
517
|
+
window +
|
|
518
|
+
actor +
|
|
256
519
|
`\n` +
|
|
257
520
|
`LAUNCH receipt: tmux created that window and was asked to start pi on the transcript above.\n` +
|
|
258
521
|
(observed
|
|
259
522
|
? `OBSERVATION receipt: the control socket for ${result.launch.targetGardenId} answered after ` +
|
|
260
|
-
`${
|
|
523
|
+
`${observation.waitedMs}ms — that citizen is addressable again, and entwurf_v2 fire-and-forget ` +
|
|
261
524
|
`now reaches it. No turn was run by this call.`
|
|
262
525
|
: `OBSERVATION receipt: resume-unobserved — the control socket did not answer within ` +
|
|
263
|
-
`${
|
|
526
|
+
`${observation.waitedMs}ms. The window is open and visible; read it directly. The lock was ` +
|
|
264
527
|
`released, nothing was retried, and nothing was killed.`),
|
|
265
528
|
isError: false,
|
|
266
529
|
};
|
|
@@ -458,6 +458,13 @@ const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:[\]-]*$/;
|
|
|
458
458
|
export function isSafeFreshCallModel(model) {
|
|
459
459
|
return model.length > 0 && model.length <= MODEL_MAX_CHARS && MODEL_PATTERN.test(model);
|
|
460
460
|
}
|
|
461
|
+
/** No model list on purpose: a hand-kept list of "good" ids would be a second catalog that rots
|
|
462
|
+
* on the next model version. The repair is to name a provider, and both sentences say what the
|
|
463
|
+
* refusal does NOT prove. */
|
|
464
|
+
export const FRESH_CALL_PROVIDER_HINT = {
|
|
465
|
+
"model-openrouter-forbidden": "OpenRouter is not a sibling rail — name a subscription or direct provider instead. This refuses the provider name written in the request (in any letter case); it does not inspect which endpoint a differently named provider resolves to",
|
|
466
|
+
"pi-model-not-provider-qualified": "a pi model is `<provider>/<model id>` — a bare alias resolves to whichever row Pi's catalog prefers, which is not a provider you chose. `entwurf_peers` shows the model each running citizen recorded; that is a record fact, not proof of the endpoint that bills it",
|
|
467
|
+
};
|
|
461
468
|
/**
|
|
462
469
|
* Normalise and validate what the CALLER supplied, in the one order both rails share.
|
|
463
470
|
*
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* fresh-call-dispatch — the ONE place that decides which rail opens a sibling, and the only module
|
|
3
3
|
* allowed to know that there are two.
|
|
4
4
|
*
|
|
5
|
-
* WHY A COMPOSITION ROOT AND NOT A BRANCH IN EACH SURFACE. Two surfaces
|
|
5
|
+
* WHY A COMPOSITION ROOT AND NOT A BRANCH IN EACH SURFACE. Two surfaces once registered
|
|
6
6
|
* `entwurf_fresh_call`: pi's own extension and the MCP bridge. Before this file each of them
|
|
7
7
|
* carried the Codex preflight ordering and the render call itself, and adding a rail choice to
|
|
8
8
|
* both would have made four places where the wrong one could be picked. A defect planted in one
|
|
9
|
-
* copy survives on the other, which is precisely the shape a mutant proves nothing about.
|
|
10
|
-
*
|
|
9
|
+
* copy survives on the other, which is precisely the shape a mutant proves nothing about. Since
|
|
10
|
+
* #125 the bridge is the only registration, and it still supplies only identity and an env; this
|
|
11
|
+
* module supplies everything else.
|
|
11
12
|
*
|
|
12
13
|
* THE RAIL IS A CAPABILITY FACT, NOT A PARAMETER. `HERDR_ENV=1` — exactly that value, herdr's own
|
|
13
14
|
* variable — means this process is inside herdr and the herdr rail is the honest placement. Every
|
|
@@ -29,11 +30,47 @@ import { codexCallerFreshPreflight, codexFreshPreflight } from "./codex-fresh-pr
|
|
|
29
30
|
import { isSafeFreshCallModel, mintNonce } from "./fresh-call-composition.js";
|
|
30
31
|
import { createHerdrRunner, herdrFreshCall, renderHerdrFreshCall, resolveHerdrContext, } from "./herdr-fresh-call.js";
|
|
31
32
|
import { freshCall, renderFreshCall } from "./mux-fresh-call.js";
|
|
33
|
+
import { splitPiDurableModel } from "./pi-durable-fresh-preflight.js";
|
|
32
34
|
/** herdr's own marker, read for its EXACT value. `[측정 2026-09-14]` herdr exports `HERDR_ENV=1`
|
|
33
35
|
* to every process it starts; anything else is not a claim herdr made. */
|
|
34
36
|
export function selectFreshCallRail(env) {
|
|
35
37
|
return env.HERDR_ENV === "1" ? "herdr" : "tmux";
|
|
36
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* #76: the provider a Pi-dialect model string NAMES. pi passes the string to `--model` and Pi
|
|
41
|
+
* resolves it; pi-durable splits it at the first `/` into `--provider`. So for those two backends
|
|
42
|
+
* the segment before the first `/` is a provider choice, and for every other backend it is that
|
|
43
|
+
* vendor's own model name, which this does not judge (a claude-code `opus` stays an alias).
|
|
44
|
+
*
|
|
45
|
+
* model-openrouter-forbidden pi or pi-durable names the OpenRouter provider. Compared in
|
|
46
|
+
* any letter case: whether Pi folds case is not measured here,
|
|
47
|
+
* so the spelling difference fails closed instead of passing.
|
|
48
|
+
* pi-model-not-provider-qualified pi names no provider at all. The alias leaves the provider
|
|
49
|
+
* choice to Pi's catalog instead of the caller naming it — a
|
|
50
|
+
* bare `terra` once opened on an OpenRouter row. pi-durable
|
|
51
|
+
* already refuses the same shape in its own preflight, by the
|
|
52
|
+
* same predicate.
|
|
53
|
+
*
|
|
54
|
+
* WHAT THIS IS NOT. A call-time input contract only: it checks the string a caller wrote, never a
|
|
55
|
+
* model version, never a list of known ids, never a resolved row or the endpoint a provider name
|
|
56
|
+
* reaches. A later model name passes THIS provider check once it is written `<provider>/<id>` —
|
|
57
|
+
* which says nothing about whether it launches or what #128 decides after it. It does not touch
|
|
58
|
+
* resume, which reopens from the record, and it proves nothing about billing beyond the name.
|
|
59
|
+
* Read trimmed, and only once the rails' grammar admits the model, exactly like #128 below.
|
|
60
|
+
*/
|
|
61
|
+
export function freshCallProviderRefusal(backend, model) {
|
|
62
|
+
if (backend !== "pi" && backend !== "pi-durable")
|
|
63
|
+
return null;
|
|
64
|
+
const trimmed = model.trim();
|
|
65
|
+
if (!isSafeFreshCallModel(trimmed))
|
|
66
|
+
return null;
|
|
67
|
+
const slash = trimmed.indexOf("/");
|
|
68
|
+
if (slash >= 0 && trimmed.slice(0, slash).toLowerCase() === "openrouter")
|
|
69
|
+
return "model-openrouter-forbidden";
|
|
70
|
+
if (backend === "pi" && splitPiDurableModel(trimmed) === null)
|
|
71
|
+
return "pi-model-not-provider-qualified";
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
37
74
|
/**
|
|
38
75
|
* #128: a pi sibling asked for under the `entwurf` provider must name an EXACT curated ACP id. Pi
|
|
39
76
|
* resolves `--model` by substring among that provider's rows, so a retired `entwurf/claude-sonnet-5`
|
|
@@ -69,6 +106,13 @@ export function isUncuratedEntwurfPiModel(backend, model) {
|
|
|
69
106
|
* them rather than inheriting a rule from a topology it does not share.
|
|
70
107
|
*/
|
|
71
108
|
export async function dispatchFreshCall(request, env = process.env, spawn, nonce = mintNonce()) {
|
|
109
|
+
// Before either rail is asked anything: both would open a pane on a provider nobody chose.
|
|
110
|
+
const providerRefusal = freshCallProviderRefusal(request.backend, request.model);
|
|
111
|
+
if (providerRefusal !== null) {
|
|
112
|
+
return selectFreshCallRail(env) === "herdr"
|
|
113
|
+
? { rail: "herdr", result: { ok: false, reason: providerRefusal } }
|
|
114
|
+
: { rail: "tmux", result: { ok: false, reason: providerRefusal } };
|
|
115
|
+
}
|
|
72
116
|
// Before either rail is asked anything: both would open a pane on the wrong row.
|
|
73
117
|
if (isUncuratedEntwurfPiModel(request.backend, request.model)) {
|
|
74
118
|
return selectFreshCallRail(env) === "herdr"
|
|
@@ -45,7 +45,7 @@ import { statSync } from "node:fs";
|
|
|
45
45
|
import path from "node:path";
|
|
46
46
|
import { HERDR_FRESH_CALL_BACKENDS } from "../../scripts/herdr-rails.mjs";
|
|
47
47
|
import { callbackEnvAssignments } from "./callback-env.js";
|
|
48
|
-
import { buildOmpBootstrapPayload, composeBackendArgs, composeFreshCallFraming, normalizeFreshCallInputs, TASK_MAX_CHARS, } from "./fresh-call-composition.js";
|
|
48
|
+
import { buildOmpBootstrapPayload, composeBackendArgs, composeFreshCallFraming, FRESH_CALL_PROVIDER_HINT, normalizeFreshCallInputs, TASK_MAX_CHARS, } from "./fresh-call-composition.js";
|
|
49
49
|
/**
|
|
50
50
|
* The pilot set, closed. `[#116 decision]` herdr's own `--kind` enum is much larger, and that is
|
|
51
51
|
* NOT evidence of support: every other backend is a pre-mutation named reject here, and there is
|
|
@@ -786,6 +786,8 @@ async function reclaim(pane, run) {
|
|
|
786
786
|
* they will guess about. The five shared input reasons keep the wording the public verb has always
|
|
787
787
|
* used; the rest are this rail's own. */
|
|
788
788
|
const HERDR_REJECT_HINT = {
|
|
789
|
+
// #76: decided at the dispatch root, worded once in the shared composition leaf.
|
|
790
|
+
...FRESH_CALL_PROVIDER_HINT,
|
|
789
791
|
"caller-identity-unavailable": "this surface has no record-backed identity, so a sibling would have nowhere to call home",
|
|
790
792
|
"model-empty": "pass the model you want the sibling opened with — it is never inherited from this process",
|
|
791
793
|
"model-invalid": "the model is not in the accepted grammar (no whitespace, no leading dash)",
|