@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.
- package/AGENTS.md +4 -2
- package/BASELINE.md +60 -5
- package/CHANGELOG.md +557 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +52 -2
- package/FAQ.md +8 -4
- package/README.md +37 -17
- package/VERIFY.md +23 -10
- package/docs/acp-backend-rail.md +5 -1
- package/docs/external-mcp-host.md +8 -6
- package/docs/fresh-cut-policy.md +9 -1
- package/docs/setup-clean-host.md +64 -9
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +100 -24
- 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-mailbox-body.js +10 -4
- 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/remote-pair-receive.js +123 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +259 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js +80 -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/dist/scripts/remote-pair-receive.js +65 -0
- package/mcp/entwurf-bridge/src/index.ts +118 -24
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/mcp/entwurf-bridge/tsconfig.build.json +17 -1
- package/package.json +5 -5
- package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
- package/pi-extensions/entwurf-control.ts +55 -20
- 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-mailbox-body.ts +13 -4
- 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/remote-pair-receive.ts +184 -0
- package/pi-extensions/lib/remote-pair-send.ts +325 -0
- package/pi-extensions/lib/remote-pair-transport.ts +81 -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 +56 -2
- package/run.sh +122 -7
- 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 +20 -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-remote-pair-installed.ts +242 -0
- package/scripts/check-resume-launch-identity.ts +62 -23
- package/scripts/lib/meta-hook-bundle.ts +72 -0
- package/scripts/lib/remote-pair-installed-audit.ts +25 -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 +2 -2
- 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/pi-durable.json +79 -0
- package/scripts/mutants/pi-retire-request.json +72 -0
- package/scripts/mutants/remote-pair-display.json +53 -0
- package/scripts/mutants/remote-pair-installed-audit.json +28 -0
- package/scripts/mutants/remote-pair-receive.json +126 -0
- package/scripts/mutants/remote-pair-roundtrip.json +83 -0
- package/scripts/mutants/remote-pair-send.json +194 -0
- package/scripts/mutants/remote-pair-transport.json +66 -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/remote-pair-receive.ts +73 -0
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +5 -4
- 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,10 +63,14 @@
|
|
|
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
|
|
|
50
76
|
import { ACP_MODEL_NOT_CURATED_HINT, AcpModelNotCuratedError } from "./acp/model-ids.js";
|
|
@@ -58,7 +84,18 @@ import {
|
|
|
58
84
|
releaseLock as realReleaseLock,
|
|
59
85
|
} from "./entwurf-v2-lock.ts";
|
|
60
86
|
import {
|
|
87
|
+
classifyMarkerOwner,
|
|
88
|
+
type MarkerOwnerVerdict,
|
|
89
|
+
metaReceiverMarkerPath,
|
|
90
|
+
parseMetaReceiverMarker,
|
|
91
|
+
probePidExistence,
|
|
92
|
+
processStartKey,
|
|
93
|
+
readStoreRecordFile,
|
|
94
|
+
} from "./meta-session.ts";
|
|
95
|
+
import {
|
|
96
|
+
type ClaudeCodeLaunchIdentity,
|
|
61
97
|
type LaunchIdentity,
|
|
98
|
+
type ResumeBackend,
|
|
62
99
|
ResumeBackendUnsupportedError,
|
|
63
100
|
resolveResumeLaunchIdentity,
|
|
64
101
|
} from "./resume-launch-identity.ts";
|
|
@@ -85,18 +122,23 @@ export const RESUME_OBSERVE_INTERVAL_MS = 500;
|
|
|
85
122
|
* ACP bridge", …), and copying those causes into an enum would duplicate a truth that already
|
|
86
123
|
* fails loud — the second copy is what goes stale.
|
|
87
124
|
*
|
|
88
|
-
* `target-
|
|
89
|
-
* to resume a
|
|
90
|
-
* is exactly right, and the operator has simply reached past a capability boundary — only
|
|
91
|
-
*
|
|
92
|
-
*
|
|
125
|
+
* `target-backend-unsupported` IS in here, and the line between it and those throws is worth
|
|
126
|
+
* stating: asking to resume a Codex or agy citizen is not a defect at all. Nothing is corrupt, the
|
|
127
|
+
* record is exactly right, and the operator has simply reached past a capability boundary — only
|
|
128
|
+
* pi and Claude Code have a same-id resume. That deserves a named refusal a caller can act on, not
|
|
129
|
+
* an error report about a store that is fine. The four `target-receiver-*` reasons are the Claude
|
|
130
|
+
* Code dormancy proof failing: nothing on disk proves the citizen is not running.
|
|
93
131
|
*/
|
|
94
132
|
export type VisibleResumeRejectReason =
|
|
95
133
|
| "target-invalid"
|
|
96
|
-
| "target-
|
|
134
|
+
| "target-backend-unsupported"
|
|
97
135
|
| "acp-model-not-curated"
|
|
98
136
|
| "target-locked"
|
|
99
137
|
| "target-live"
|
|
138
|
+
| "target-receiver-absent"
|
|
139
|
+
| "target-receiver-corrupt"
|
|
140
|
+
| "target-receiver-mismatch"
|
|
141
|
+
| "target-receiver-unbound"
|
|
100
142
|
| "target-indeterminate"
|
|
101
143
|
| "target-address-conflict"
|
|
102
144
|
| "launch-refused";
|
|
@@ -118,14 +160,12 @@ export type VisibleResumeLaunchOutcome =
|
|
|
118
160
|
| { ok: true; handle: VisibleResumeLaunchHandle }
|
|
119
161
|
| { ok: false; reason: string; hint: string };
|
|
120
162
|
|
|
121
|
-
/** Fact 1: tmux created a window and was asked to start
|
|
122
|
-
* directory (what tmux was told), the same kind of fact as `runtimePath` — not an
|
|
123
|
-
* where the pane landed. */
|
|
124
|
-
export
|
|
125
|
-
targetGardenId: string;
|
|
126
|
-
|
|
127
|
-
handle: VisibleResumeLaunchHandle;
|
|
128
|
-
}
|
|
163
|
+
/** Fact 1: tmux created a window and was asked to start the citizen's runtime there. `cwd` is the
|
|
164
|
+
* REQUESTED start directory (what tmux was told), the same kind of fact as `runtimePath` — not an
|
|
165
|
+
* observation of where the pane landed. */
|
|
166
|
+
export type ResumeLaunchReceipt =
|
|
167
|
+
| { backend: "pi"; targetGardenId: string; sessionFile: string; handle: VisibleResumeLaunchHandle }
|
|
168
|
+
| { backend: "claude-code"; targetGardenId: string; nativeSessionId: string; handle: VisibleResumeLaunchHandle };
|
|
129
169
|
|
|
130
170
|
/** Fact 2: the citizen's control socket came back under the SAME garden id, or it did not. This
|
|
131
171
|
* is the only fact in this module that says a citizen is back. */
|
|
@@ -135,8 +175,39 @@ export interface ResumeObservationReceipt {
|
|
|
135
175
|
waitedMs: number;
|
|
136
176
|
}
|
|
137
177
|
|
|
178
|
+
/** Fact 2 for Claude Code: a NEW live owner in the opened pane re-armed the receiver marker for the
|
|
179
|
+
* same native session with a fresh binding the current record carries, or that was not seen in
|
|
180
|
+
* time, or the record could not be read (a store defect, reported at once with its cause — the
|
|
181
|
+
* window stays open). The pid is the new owner's, read from the marker and joined to the pane
|
|
182
|
+
* this call opened; it is evidence, not an address. */
|
|
183
|
+
export type ReceiverObservationReceipt =
|
|
184
|
+
| { kind: "receiver-armed"; ownerPid: number; waitedMs: number }
|
|
185
|
+
| { kind: "resume-unobserved"; waitedMs: number }
|
|
186
|
+
| { kind: "record-unreadable"; cause: string; waitedMs: number };
|
|
187
|
+
|
|
188
|
+
/** What the receiver marker under a garden id says, read raw and judged by the shared owner rule. */
|
|
189
|
+
export type ReceiverInspection =
|
|
190
|
+
| { kind: "absent" }
|
|
191
|
+
| { kind: "corrupt" }
|
|
192
|
+
| {
|
|
193
|
+
kind: "marker";
|
|
194
|
+
marker: {
|
|
195
|
+
gardenId: string;
|
|
196
|
+
backend: string;
|
|
197
|
+
nativeSessionId: string;
|
|
198
|
+
ownerPid: number;
|
|
199
|
+
ownerStartKey: string;
|
|
200
|
+
attachIncarnation: string | null;
|
|
201
|
+
/** Already parsed from the same fd; exposed for callers that must join the owner's domain
|
|
202
|
+
* (#147 G3 retire). The resume verdict does not read them. */
|
|
203
|
+
ownerKind: string;
|
|
204
|
+
armProvenance: string;
|
|
205
|
+
};
|
|
206
|
+
owner: MarkerOwnerVerdict;
|
|
207
|
+
};
|
|
208
|
+
|
|
138
209
|
export type VisibleResumeResult =
|
|
139
|
-
| { ok: true; launch: ResumeLaunchReceipt; observation: ResumeObservationReceipt }
|
|
210
|
+
| { ok: true; launch: ResumeLaunchReceipt; observation: ResumeObservationReceipt | ReceiverObservationReceipt }
|
|
140
211
|
| { ok: false; reason: VisibleResumeRejectReason; detail: string };
|
|
141
212
|
|
|
142
213
|
export interface VisibleResumeDeps {
|
|
@@ -145,8 +216,14 @@ export interface VisibleResumeDeps {
|
|
|
145
216
|
resolveIdentity: (gardenId: string) => LaunchIdentity;
|
|
146
217
|
inspectSocket: (gardenId: string) => Promise<TargetSocketInspection>;
|
|
147
218
|
probeSocket: (socketPath: string) => Promise<SocketLiveness>;
|
|
219
|
+
/** The Claude Code dormancy proof and observation read this; pi never does. */
|
|
220
|
+
inspectReceiver: (gardenId: string) => ReceiverInspection;
|
|
148
221
|
/** The ONLY way this module reaches tmux. The surface supplies it. */
|
|
149
|
-
launch: (input: {
|
|
222
|
+
launch: (input: {
|
|
223
|
+
backend: ResumeBackend;
|
|
224
|
+
cwd: string;
|
|
225
|
+
runtimeArgs: readonly string[];
|
|
226
|
+
}) => VisibleResumeLaunchOutcome;
|
|
150
227
|
sleep: (ms: number) => Promise<void>;
|
|
151
228
|
now: () => number;
|
|
152
229
|
}
|
|
@@ -163,6 +240,7 @@ export function makeVisibleResumeDeps(
|
|
|
163
240
|
resolveIdentity: resolveResumeLaunchIdentity,
|
|
164
241
|
inspectSocket: (gid) => inspectTargetControlSocket(gid, CONTROL_SOCKET_DIR),
|
|
165
242
|
probeSocket: (socketPath) => probeSocketLiveness(socketPath),
|
|
243
|
+
inspectReceiver: (gid) => inspectReceiverMarker(gid),
|
|
166
244
|
launch,
|
|
167
245
|
sleep: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
|
168
246
|
now: () => Date.now(),
|
|
@@ -170,6 +248,50 @@ export function makeVisibleResumeDeps(
|
|
|
170
248
|
};
|
|
171
249
|
}
|
|
172
250
|
|
|
251
|
+
/**
|
|
252
|
+
* The receiver marker under `gardenId`, read RAW (no owner verification inside the reader — the
|
|
253
|
+
* path reader's `null` folds absent, corrupt and dead into one answer, and only "dead" may permit
|
|
254
|
+
* a resume) and judged by `classifyMarkerOwner`, the one place a dead verdict is produced. The
|
|
255
|
+
* bytes come from `readStoreRecordFile` — `O_NOFOLLOW` open, `fstat` kind and read on that one fd
|
|
256
|
+
* (#147 L2 D3) — so a symlink or a non-regular entry is corrupt and never followed, with no
|
|
257
|
+
* window between classifying the entry and reading it. `ENOENT` alone is absent.
|
|
258
|
+
*/
|
|
259
|
+
export function inspectReceiverMarker(gardenId: string, receiversDir?: string): ReceiverInspection {
|
|
260
|
+
const markerPath = metaReceiverMarkerPath(gardenId, receiversDir);
|
|
261
|
+
let bytes: string;
|
|
262
|
+
try {
|
|
263
|
+
bytes = readStoreRecordFile(markerPath);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
if ((err as { code?: unknown }).code === "ENOENT") return { kind: "absent" };
|
|
266
|
+
return { kind: "corrupt" };
|
|
267
|
+
}
|
|
268
|
+
let marker: ReturnType<typeof parseMetaReceiverMarker>;
|
|
269
|
+
try {
|
|
270
|
+
marker = parseMetaReceiverMarker(bytes);
|
|
271
|
+
} catch {
|
|
272
|
+
return { kind: "corrupt" };
|
|
273
|
+
}
|
|
274
|
+
if (marker === null) return { kind: "corrupt" };
|
|
275
|
+
const owner = classifyMarkerOwner(marker.ownerStartKey, {
|
|
276
|
+
currentStartKey: processStartKey(marker.ownerPid),
|
|
277
|
+
pidExists: probePidExistence(marker.ownerPid),
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
kind: "marker",
|
|
281
|
+
marker: {
|
|
282
|
+
gardenId: marker.gardenId,
|
|
283
|
+
backend: marker.backend,
|
|
284
|
+
nativeSessionId: marker.nativeSessionId,
|
|
285
|
+
ownerPid: marker.ownerPid,
|
|
286
|
+
ownerStartKey: marker.ownerStartKey,
|
|
287
|
+
attachIncarnation: marker.attachIncarnation,
|
|
288
|
+
ownerKind: marker.ownerKind,
|
|
289
|
+
armProvenance: marker.armProvenance,
|
|
290
|
+
},
|
|
291
|
+
owner,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
173
295
|
/**
|
|
174
296
|
* Reopen `targetGardenId` in a visible window.
|
|
175
297
|
*
|
|
@@ -230,7 +352,7 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
|
|
|
230
352
|
identity = deps.resolveIdentity(targetGardenId);
|
|
231
353
|
} catch (err) {
|
|
232
354
|
// #128: the recorded model is not an exact curated ACP id, so Pi would pick another row
|
|
233
|
-
// by substring. Same shape as `target-
|
|
355
|
+
// by substring. Same shape as `target-backend-unsupported`: a named refusal matched on its FIELD.
|
|
234
356
|
if (err instanceof AcpModelNotCuratedError) {
|
|
235
357
|
return {
|
|
236
358
|
ok: false,
|
|
@@ -243,13 +365,15 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
|
|
|
243
365
|
ok: false,
|
|
244
366
|
reason: err.reason,
|
|
245
367
|
detail:
|
|
246
|
-
`${targetGardenId} is a ${err.backend} citizen; only pi
|
|
247
|
-
`
|
|
368
|
+
`${targetGardenId} is a ${err.backend} citizen; only pi and Claude Code citizens can be reopened ` +
|
|
369
|
+
`under the same garden id`,
|
|
248
370
|
};
|
|
249
371
|
}
|
|
250
372
|
throw err;
|
|
251
373
|
}
|
|
252
374
|
|
|
375
|
+
if (identity.backend === "claude-code") return await resumeClaudeCode(targetGardenId, identity, deps);
|
|
376
|
+
|
|
253
377
|
// Liveness preflight, still under the lock. A live citizen is addressed with
|
|
254
378
|
// `entwurf_v2 fire-and-forget`, not reopened — and refusing here is also what stops two
|
|
255
379
|
// windows existing for one garden id.
|
|
@@ -287,7 +411,7 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
|
|
|
287
411
|
provider: identity.provider,
|
|
288
412
|
model: identity.model,
|
|
289
413
|
});
|
|
290
|
-
const launched = deps.launch({ cwd: identity.cwd, runtimeArgs });
|
|
414
|
+
const launched = deps.launch({ backend: "pi", cwd: identity.cwd, runtimeArgs });
|
|
291
415
|
if (!launched.ok) {
|
|
292
416
|
return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
|
|
293
417
|
}
|
|
@@ -295,7 +419,7 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
|
|
|
295
419
|
const observation = await observeSameGidSocket(targetGardenId, mapped.socketPath, deps);
|
|
296
420
|
return {
|
|
297
421
|
ok: true,
|
|
298
|
-
launch: { targetGardenId, sessionFile: identity.sessionFile, handle: launched.handle },
|
|
422
|
+
launch: { backend: "pi", targetGardenId, sessionFile: identity.sessionFile, handle: launched.handle },
|
|
299
423
|
observation,
|
|
300
424
|
};
|
|
301
425
|
} finally {
|
|
@@ -303,6 +427,186 @@ export async function visibleResume(targetGardenId: string, deps: VisibleResumeD
|
|
|
303
427
|
}
|
|
304
428
|
}
|
|
305
429
|
|
|
430
|
+
/**
|
|
431
|
+
* The Claude Code half, still under the caller's lock. Dormancy needs a DEAD owner verdict on the
|
|
432
|
+
* receiver marker AND the binding: the record re-read AFTER that verdict must still name the same
|
|
433
|
+
* session and createdAt, and carry the same non-null `attachIncarnation` the marker copied. An
|
|
434
|
+
* absent marker (never armed, or retired when its process switched session), an unreadable one,
|
|
435
|
+
* one that names another session, a live owner, an inconclusive one and an unbound one each refuse
|
|
436
|
+
* by name, with no window. The re-read catches every attach whose atomic record write committed
|
|
437
|
+
* before it; a launch or attach that begins after it is not predicted. The launch uses the
|
|
438
|
+
* re-read record (its cwd), never the first read. The marker and the record are never removed or
|
|
439
|
+
* rewritten here — they are evidence, not something this verb may clear.
|
|
440
|
+
*/
|
|
441
|
+
async function resumeClaudeCode(
|
|
442
|
+
targetGardenId: string,
|
|
443
|
+
identity: ClaudeCodeLaunchIdentity,
|
|
444
|
+
deps: VisibleResumeDeps,
|
|
445
|
+
): Promise<VisibleResumeResult> {
|
|
446
|
+
const before = deps.inspectReceiver(targetGardenId);
|
|
447
|
+
if (before.kind === "absent") {
|
|
448
|
+
return {
|
|
449
|
+
ok: false,
|
|
450
|
+
reason: "target-receiver-absent",
|
|
451
|
+
detail: `no receiver marker names ${targetGardenId}, so nothing proves its Claude Code process is gone`,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
if (before.kind === "corrupt") {
|
|
455
|
+
return {
|
|
456
|
+
ok: false,
|
|
457
|
+
reason: "target-receiver-corrupt",
|
|
458
|
+
detail: `the receiver marker for ${targetGardenId} is not a readable regular marker, so its owner cannot be judged`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
const marker = before.marker;
|
|
462
|
+
if (
|
|
463
|
+
marker.gardenId !== targetGardenId ||
|
|
464
|
+
marker.backend !== "claude-code" ||
|
|
465
|
+
marker.nativeSessionId !== identity.nativeSessionId
|
|
466
|
+
) {
|
|
467
|
+
return {
|
|
468
|
+
ok: false,
|
|
469
|
+
reason: "target-receiver-mismatch",
|
|
470
|
+
detail:
|
|
471
|
+
`the receiver marker for ${targetGardenId} names ${marker.backend}/${marker.nativeSessionId}, not the ` +
|
|
472
|
+
`record's claude-code/${identity.nativeSessionId}`,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
if (before.owner === "live") {
|
|
476
|
+
return {
|
|
477
|
+
ok: false,
|
|
478
|
+
reason: "target-live",
|
|
479
|
+
detail: `${targetGardenId}'s Claude Code process (pid ${marker.ownerPid}) is still running — send to it with entwurf_v2 fire-and-forget instead`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (before.owner !== "dead") {
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
reason: "target-indeterminate",
|
|
486
|
+
detail:
|
|
487
|
+
`the receiver marker's owner (pid ${marker.ownerPid}) for ${targetGardenId} could not be proven gone, ` +
|
|
488
|
+
`and resuming a session that is still open would put two processes on one transcript`,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// The binding, read AFTER the dead verdict. A store or record defect here throws, exactly as
|
|
493
|
+
// the first read does: no window exists yet.
|
|
494
|
+
const current = deps.resolveIdentity(targetGardenId);
|
|
495
|
+
if (
|
|
496
|
+
current.backend !== "claude-code" ||
|
|
497
|
+
current.nativeSessionId !== identity.nativeSessionId ||
|
|
498
|
+
current.createdAt !== identity.createdAt
|
|
499
|
+
) {
|
|
500
|
+
return {
|
|
501
|
+
ok: false,
|
|
502
|
+
reason: "target-receiver-unbound",
|
|
503
|
+
detail: `the record under ${targetGardenId} changed between the two reads, so the dead marker cannot be bound to it`,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
if (current.attachIncarnation === null || marker.attachIncarnation === null) {
|
|
507
|
+
return {
|
|
508
|
+
ok: false,
|
|
509
|
+
reason: "target-receiver-unbound",
|
|
510
|
+
detail:
|
|
511
|
+
`no arm binding was observed for ${targetGardenId} (record ${current.attachIncarnation === null ? "has none" : "has one"}, ` +
|
|
512
|
+
`marker ${marker.attachIncarnation === null ? "has none" : "has one"}), so the dead owner is not proven to be the last attach`,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
if (current.attachIncarnation !== marker.attachIncarnation) {
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
reason: "target-receiver-unbound",
|
|
519
|
+
detail:
|
|
520
|
+
`the record of ${targetGardenId} carries a later arm binding than its receiver marker — an attach committed ` +
|
|
521
|
+
`after the dead owner's, and its process is not proven gone`,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const launched = deps.launch({
|
|
526
|
+
backend: "claude-code",
|
|
527
|
+
cwd: current.cwd,
|
|
528
|
+
runtimeArgs: ["--resume", current.nativeSessionId],
|
|
529
|
+
});
|
|
530
|
+
if (!launched.ok) {
|
|
531
|
+
return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
|
|
532
|
+
}
|
|
533
|
+
const observation = await observeReceiverReattached(
|
|
534
|
+
targetGardenId,
|
|
535
|
+
current,
|
|
536
|
+
{ ownerPid: marker.ownerPid, ownerStartKey: marker.ownerStartKey, attachIncarnation: current.attachIncarnation },
|
|
537
|
+
launched.handle.panePid,
|
|
538
|
+
deps,
|
|
539
|
+
);
|
|
540
|
+
return {
|
|
541
|
+
ok: true,
|
|
542
|
+
launch: {
|
|
543
|
+
backend: "claude-code",
|
|
544
|
+
targetGardenId,
|
|
545
|
+
nativeSessionId: current.nativeSessionId,
|
|
546
|
+
handle: launched.handle,
|
|
547
|
+
},
|
|
548
|
+
observation,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* ONE bounded operation, the Claude Code twin of `observeSameGidSocket`: wait for a NEW live owner
|
|
554
|
+
* — in the pane this call opened (`panePid`), a join, not an address — to re-arm this garden id's
|
|
555
|
+
* receiver marker for the same native session with a fresh binding the current record carries
|
|
556
|
+
* (same createdAt). The dead marker read again is never success: the owner must differ from the one
|
|
557
|
+
* the dormancy proof judged dead, and the binding from the one it bound. A marker armed by some
|
|
558
|
+
* other process is simply not-yet, and ends as `resume-unobserved`. A record or store defect is not
|
|
559
|
+
* a startup state: it stops the wait at once as `record-unreadable`, with its cause.
|
|
560
|
+
*/
|
|
561
|
+
export async function observeReceiverReattached(
|
|
562
|
+
targetGardenId: string,
|
|
563
|
+
identity: ClaudeCodeLaunchIdentity,
|
|
564
|
+
previous: { ownerPid: number; ownerStartKey: string; attachIncarnation: string },
|
|
565
|
+
panePid: string,
|
|
566
|
+
deps: Pick<VisibleResumeDeps, "inspectReceiver" | "resolveIdentity" | "sleep" | "now">,
|
|
567
|
+
timeoutMs: number = RESUME_OBSERVE_TIMEOUT_MS,
|
|
568
|
+
intervalMs: number = RESUME_OBSERVE_INTERVAL_MS,
|
|
569
|
+
): Promise<ReceiverObservationReceipt> {
|
|
570
|
+
const started = deps.now();
|
|
571
|
+
for (;;) {
|
|
572
|
+
const seen = deps.inspectReceiver(targetGardenId);
|
|
573
|
+
if (
|
|
574
|
+
seen.kind === "marker" &&
|
|
575
|
+
seen.owner === "live" &&
|
|
576
|
+
seen.marker.gardenId === targetGardenId &&
|
|
577
|
+
seen.marker.backend === "claude-code" &&
|
|
578
|
+
seen.marker.nativeSessionId === identity.nativeSessionId &&
|
|
579
|
+
String(seen.marker.ownerPid) === panePid &&
|
|
580
|
+
(seen.marker.ownerPid !== previous.ownerPid || seen.marker.ownerStartKey !== previous.ownerStartKey) &&
|
|
581
|
+
seen.marker.attachIncarnation !== null &&
|
|
582
|
+
seen.marker.attachIncarnation !== previous.attachIncarnation
|
|
583
|
+
) {
|
|
584
|
+
let current: LaunchIdentity;
|
|
585
|
+
try {
|
|
586
|
+
current = deps.resolveIdentity(targetGardenId);
|
|
587
|
+
} catch (err) {
|
|
588
|
+
return {
|
|
589
|
+
kind: "record-unreadable",
|
|
590
|
+
cause: err instanceof Error ? err.message : String(err),
|
|
591
|
+
waitedMs: deps.now() - started,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
if (
|
|
595
|
+
current.backend === "claude-code" &&
|
|
596
|
+
current.nativeSessionId === identity.nativeSessionId &&
|
|
597
|
+
current.createdAt === identity.createdAt &&
|
|
598
|
+
current.attachIncarnation === seen.marker.attachIncarnation
|
|
599
|
+
) {
|
|
600
|
+
return { kind: "receiver-armed", ownerPid: seen.marker.ownerPid, waitedMs: deps.now() - started };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (deps.now() - started >= timeoutMs) {
|
|
604
|
+
return { kind: "resume-unobserved", waitedMs: deps.now() - started };
|
|
605
|
+
}
|
|
606
|
+
await deps.sleep(intervalMs);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
306
610
|
/**
|
|
307
611
|
* ONE bounded operation: wait for the SAME garden id's control socket to answer. Alive is the
|
|
308
612
|
* only early exit — an inspection that comes back absent, corrupt or inconclusive during startup
|
|
@@ -331,11 +635,20 @@ export async function observeSameGidSocket(
|
|
|
331
635
|
|
|
332
636
|
const REJECT_HINT: Record<VisibleResumeRejectReason, string> = {
|
|
333
637
|
"target-invalid": "no citizen has that id; discover targets with entwurf_peers",
|
|
334
|
-
"target-
|
|
638
|
+
"target-backend-unsupported":
|
|
639
|
+
"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",
|
|
335
640
|
"acp-model-not-curated": ACP_MODEL_NOT_CURATED_HINT,
|
|
336
641
|
"target-locked": "another resume or dispatch holds this citizen's lock right now",
|
|
337
642
|
"target-live": "that citizen is already running — this verb only reopens dormant ones",
|
|
338
|
-
"target-indeterminate":
|
|
643
|
+
"target-indeterminate":
|
|
644
|
+
"the citizen could not be proven dormant (an inconclusive socket probe or receiver owner), so nothing was started",
|
|
645
|
+
"target-receiver-absent":
|
|
646
|
+
"a Claude Code citizen is proven dormant only by its receiver marker, and it has none — if its window is closed, reopen it yourself",
|
|
647
|
+
"target-receiver-corrupt": "the citizen's receiver marker cannot be read as a marker, so its owner cannot be judged",
|
|
648
|
+
"target-receiver-mismatch":
|
|
649
|
+
"the receiver marker under this garden id names another backend or native session than the record — look at the store before anything reopens",
|
|
650
|
+
"target-receiver-unbound":
|
|
651
|
+
"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",
|
|
339
652
|
"target-address-conflict": "that citizen's control-socket path is corrupt",
|
|
340
653
|
"launch-refused": "tmux was never asked to open a window",
|
|
341
654
|
};
|
|
@@ -349,7 +662,10 @@ const REJECT_HINT: Record<VisibleResumeRejectReason, string> = {
|
|
|
349
662
|
* who sees "window opened" and "socket alive" as one sentence has been told the resume worked;
|
|
350
663
|
* a reader who sees them as two facts can tell which one is missing when only one is there.
|
|
351
664
|
*/
|
|
352
|
-
export function renderVisibleResume(
|
|
665
|
+
export function renderVisibleResume(
|
|
666
|
+
result: VisibleResumeResult,
|
|
667
|
+
attribution: { actor: string | null } = { actor: null },
|
|
668
|
+
): { text: string; isError: boolean } {
|
|
353
669
|
if (!result.ok) {
|
|
354
670
|
return {
|
|
355
671
|
text:
|
|
@@ -359,23 +675,61 @@ export function renderVisibleResume(result: VisibleResumeResult): { text: string
|
|
|
359
675
|
};
|
|
360
676
|
}
|
|
361
677
|
const h = result.launch.handle;
|
|
362
|
-
|
|
678
|
+
// Who asked is recorded, and it is not authority: the verb decides from the target's record and
|
|
679
|
+
// live rail facts alone, whoever the caller is.
|
|
680
|
+
const actor =
|
|
681
|
+
attribution.actor === null
|
|
682
|
+
? ` actor: unattributed (the caller has no record-backed garden identity)\n`
|
|
683
|
+
: ` actor: ${attribution.actor} (record-backed caller; attribution, not authority)\n`;
|
|
684
|
+
const window =
|
|
685
|
+
` runtime: ${h.runtimePath} (requested start cwd ${h.cwd})\n` +
|
|
686
|
+
` window: ${h.windowId} (index ${h.windowIndex}) in session ${h.sessionId}\n` +
|
|
687
|
+
` pane: ${h.paneId} pid ${h.panePid}\n`;
|
|
688
|
+
if (result.launch.backend === "claude-code") {
|
|
689
|
+
const observation = result.observation as ReceiverObservationReceipt;
|
|
690
|
+
return {
|
|
691
|
+
text:
|
|
692
|
+
`[entwurf resume call ↻]\n` +
|
|
693
|
+
` target: ${result.launch.targetGardenId}\n` +
|
|
694
|
+
` backend: claude-code\n` +
|
|
695
|
+
` session: ${result.launch.nativeSessionId}\n` +
|
|
696
|
+
window +
|
|
697
|
+
actor +
|
|
698
|
+
`\n` +
|
|
699
|
+
`LAUNCH receipt: tmux created that window and was asked to start claude --resume on the session above.\n` +
|
|
700
|
+
(observation.kind === "receiver-armed"
|
|
701
|
+
? `OBSERVATION receipt: receiver-armed — a NEW live Claude Code process in the window this call opened ` +
|
|
702
|
+
`(pid ${observation.ownerPid}) re-armed the receiver marker of ${result.launch.targetGardenId} for the ` +
|
|
703
|
+
`same session after ${observation.waitedMs}ms, with a fresh arm binding the record also carries ` +
|
|
704
|
+
`(same createdAt). entwurf_v2 fire-and-forget now reaches its mailbox. This call submitted no ` +
|
|
705
|
+
`prompt; the runtime's own activity after resume (e.g. a doorbell on unread mail) is unmeasured.`
|
|
706
|
+
: observation.kind === "record-unreadable"
|
|
707
|
+
? `OBSERVATION receipt: record-unreadable — the record of ${result.launch.targetGardenId} could not be ` +
|
|
708
|
+
`read after ${observation.waitedMs}ms: ${observation.cause}. The window is open and visible; the ` +
|
|
709
|
+
`lock was released, nothing was retried, and nothing was killed.`
|
|
710
|
+
: `OBSERVATION receipt: resume-unobserved — no new live receiver marker from the window this call ` +
|
|
711
|
+
`opened, bound to the record, appeared within ${observation.waitedMs}ms. The window is open and ` +
|
|
712
|
+
`visible; read it directly. The lock was released, nothing was retried, and nothing was killed.`),
|
|
713
|
+
isError: false,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
const observation = result.observation as ResumeObservationReceipt;
|
|
717
|
+
const observed = observation.kind === "socket-alive";
|
|
363
718
|
return {
|
|
364
719
|
text:
|
|
365
720
|
`[entwurf resume call ↻]\n` +
|
|
366
721
|
` target: ${result.launch.targetGardenId}\n` +
|
|
367
722
|
` session: ${result.launch.sessionFile}\n` +
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
` pane: ${h.paneId} pid ${h.panePid}\n` +
|
|
723
|
+
window +
|
|
724
|
+
actor +
|
|
371
725
|
`\n` +
|
|
372
726
|
`LAUNCH receipt: tmux created that window and was asked to start pi on the transcript above.\n` +
|
|
373
727
|
(observed
|
|
374
728
|
? `OBSERVATION receipt: the control socket for ${result.launch.targetGardenId} answered after ` +
|
|
375
|
-
`${
|
|
729
|
+
`${observation.waitedMs}ms — that citizen is addressable again, and entwurf_v2 fire-and-forget ` +
|
|
376
730
|
`now reaches it. No turn was run by this call.`
|
|
377
731
|
: `OBSERVATION receipt: resume-unobserved — the control socket did not answer within ` +
|
|
378
|
-
`${
|
|
732
|
+
`${observation.waitedMs}ms. The window is open and visible; read it directly. The lock was ` +
|
|
379
733
|
`released, nothing was retried, and nothing was killed.`),
|
|
380
734
|
isError: false,
|
|
381
735
|
};
|
|
@@ -526,6 +526,21 @@ export type FreshCallInputRejectReason =
|
|
|
526
526
|
| "task-empty"
|
|
527
527
|
| "task-too-long";
|
|
528
528
|
|
|
529
|
+
/** #76: the provider a Pi-dialect model string NAMES, refused at the dispatch root before either
|
|
530
|
+
* rail is asked anything (`fresh-call-dispatch.ts` decides; the words live here so both rails, one
|
|
531
|
+
* of which admits no other shared module, answer with the same sentence). */
|
|
532
|
+
export type FreshCallProviderRejectReason = "model-openrouter-forbidden" | "pi-model-not-provider-qualified";
|
|
533
|
+
|
|
534
|
+
/** No model list on purpose: a hand-kept list of "good" ids would be a second catalog that rots
|
|
535
|
+
* on the next model version. The repair is to name a provider, and both sentences say what the
|
|
536
|
+
* refusal does NOT prove. */
|
|
537
|
+
export const FRESH_CALL_PROVIDER_HINT: Record<FreshCallProviderRejectReason, string> = {
|
|
538
|
+
"model-openrouter-forbidden":
|
|
539
|
+
"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",
|
|
540
|
+
"pi-model-not-provider-qualified":
|
|
541
|
+
"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",
|
|
542
|
+
};
|
|
543
|
+
|
|
529
544
|
export interface FreshCallInputs {
|
|
530
545
|
readonly callerGardenId: string;
|
|
531
546
|
readonly model: string;
|