@junghanacs/entwurf 0.45.0 → 0.51.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 +5 -3
- package/BASELINE.md +110 -1
- package/CHANGELOG.md +249 -0
- package/CONTRIBUTING.md +1 -1
- package/ENTWURF-CLI.json +1229 -0
- package/ENTWURF.json +63 -15
- package/FAQ.md +4 -4
- package/README.md +132 -188
- package/VERIFY.md +18 -11
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/external-mcp-host.md +4 -4
- package/docs/setup-clean-host.md +94 -22
- package/mcp/entwurf-bridge/dist/ENTWURF.json +63 -15
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +19 -8
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +483 -18
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +613 -33
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-resume-call.js +497 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +50 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-location.js +347 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-owner-fence.js +1132 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +18 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +30 -15
- package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +40 -7
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +1 -27
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +1 -1
- package/mcp/entwurf-bridge/src/index.ts +28 -8
- package/mcp/entwurf-bridge/tsconfig.build.json +7 -1
- package/native/win32-x64/manifest.json +1 -1
- package/package.json +16 -20
- package/pi/pi-durable/bootstrap.mjs +179 -6
- package/pi/pi-durable/carrier/runtime.js +327 -57
- package/pi/pi-durable/carrier/session-name.js +11 -0
- package/pi/pi-durable/carrier/sessions.js +96 -20
- package/pi/pi-durable/carrier/subagent.js +5 -2
- package/pi/pi-durable/carrier/tui.js +60 -22
- package/pi/pi-durable/overlay/upstream-pin.json +26 -19
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +73 -69
- package/pi-extensions/lib/entwurf-v2-contract.ts +4 -4
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +710 -38
- package/pi-extensions/lib/fresh-call-dispatch.ts +64 -12
- package/pi-extensions/lib/herdr-fresh-call.ts +919 -40
- package/pi-extensions/lib/herdr-resume-call.ts +810 -0
- package/pi-extensions/lib/meta-session.ts +13 -8
- package/pi-extensions/lib/mux-resume-call.ts +77 -4
- package/pi-extensions/lib/pi-durable-fresh-preflight.ts +9 -2
- package/pi-extensions/lib/pi-durable-location.ts +446 -0
- package/pi-extensions/lib/pi-durable-owner-fence.ts +1461 -0
- package/pi-extensions/lib/pi-durable-runtime.ts +4 -0
- package/pi-extensions/lib/resume-launch-identity.ts +31 -12
- package/pi-extensions/meta-bridge-pi-durable.ts +30 -16
- package/run.sh +552 -699
- package/scripts/check-claude-launch.ts +341 -0
- package/scripts/check-entwurf-v2-contract.ts +4 -4
- package/scripts/check-entwurf-v2-surface.ts +2 -2
- package/scripts/check-fresh-call-dispatch.ts +70 -9
- package/scripts/check-fresh-cut-gate.sh +23 -9
- package/scripts/check-gate-qualification.ts +19 -14
- package/scripts/check-herdr-activation.ts +301 -23
- package/scripts/check-herdr-fresh-call.ts +161 -25
- package/scripts/check-herdr-plugin-build.ts +173 -1
- package/scripts/check-herdr-plugin.ts +28 -2
- package/scripts/check-herdr-runtime-bootstrap.ts +136 -0
- package/scripts/check-install-surface.ts +1236 -6
- package/scripts/check-meta-doctor-oracle.sh +1 -1
- package/scripts/check-meta-facts.ts +20 -1
- package/scripts/check-mux-resume-call.ts +28 -0
- package/scripts/check-peer-facts.ts +39 -2
- package/scripts/cli-help.mjs +222 -0
- package/scripts/dev-bin.sh +39 -2
- package/scripts/herdr-activation.mjs +28 -6
- package/scripts/herdr-operator-link.mjs +389 -0
- package/scripts/herdr-plugin-activate.mjs +5 -0
- package/scripts/herdr-plugin-deactivate.mjs +18 -5
- package/scripts/herdr-rails.mjs +43 -7
- package/scripts/herdr-runtime.mjs +27 -0
- package/scripts/meta-bridge-fresh-cut.ts +1 -27
- package/scripts/meta-bridge-prune.ts +1 -1
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/agent-guidance.json +4 -4
- package/scripts/mutants/claude-launch.json +142 -0
- package/scripts/mutants/cli-help-source.json +420 -0
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/dev-gate-dispatch.json +109 -0
- package/scripts/mutants/fresh-call-dispatch.json +38 -2
- package/scripts/mutants/fresh-cut.json +15 -0
- package/scripts/mutants/herdr-activation.json +128 -0
- package/scripts/mutants/herdr-fresh-call.json +278 -5
- package/scripts/mutants/herdr-operator-link.json +318 -0
- package/scripts/mutants/herdr-plugin-build.json +55 -0
- package/scripts/mutants/herdr-plugin.json +17 -0
- package/scripts/mutants/herdr-resume-call.json +288 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +48 -0
- package/scripts/mutants/meta-facts.json +15 -0
- package/scripts/mutants/mux-resume-call.json +63 -2
- package/scripts/mutants/pack-install.json +4 -4
- package/scripts/mutants/peer-facts.json +15 -0
- package/scripts/mutants/pi-durable.json +1167 -3
- package/scripts/mutants/pi-floor.json +22 -5
- package/scripts/mutants/source-install.json +35 -0
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +255 -6
- package/scripts/peer-facts.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +66 -0
- package/scripts/smoke-meta-prune.sh +23 -0
- package/scripts/smoke-omp-fresh-live.ts +12 -3
- package/scripts/smoke-omp-receive-live.ts +7 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +12 -2
- package/team.json.example +39 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* herdr-resume-call — reopen ONE dormant pi citizen's own transcript in a new herdr tab, from inside
|
|
3
|
+
* herdr (#157 G1a).
|
|
4
|
+
*
|
|
5
|
+
* WHAT THIS IS. The herdr half of `entwurf_resume_call`, and only its LAUNCH. The v2 composition
|
|
6
|
+
* (`entwurf-v2-visible-resume.ts`) owns everything that makes a resume a resume: the record, the
|
|
7
|
+
* per-gid lock, the dormancy proof and the same-gid control-socket observation. It reaches this
|
|
8
|
+
* module through the injected launch seam the bridge composes (`mcp/entwurf-bridge/src/index.ts`),
|
|
9
|
+
* exactly as it reaches the tmux leaf `mux-resume-call.ts`; which of the two runs is chosen by the
|
|
10
|
+
* shared rail root (`dispatchResumeLaunch` in `fresh-call-dispatch.ts`), not by the bridge and not
|
|
11
|
+
* here — so this file never sees an address, never reads a record and never decides whether the
|
|
12
|
+
* citizen came back.
|
|
13
|
+
*
|
|
14
|
+
* PI ONLY ON THIS PATH. A Claude Code resume proves its return by joining the receiver marker's owner
|
|
15
|
+
* pid to the pid of the process its launch started, and `agent start` types the command into the
|
|
16
|
+
* pane's interactive shell, so the pane's direct child is that shell, not Claude Code. A Claude Code
|
|
17
|
+
* target therefore launches through ONE layout.apply instead (`herdrClaudeCodeResumeCall` below), and
|
|
18
|
+
* like every herdr refusal nothing here falls back to tmux: a window somewhere the operator is not
|
|
19
|
+
* looking is worse than a refusal.
|
|
20
|
+
*
|
|
21
|
+
* WHAT IS REUSED AND WHAT IS NOT. Every herdr protocol primitive comes from `herdr-fresh-call.ts`:
|
|
22
|
+
* the context, the cwd classification, the caller-pane read, the tab-create reply parser, the start
|
|
23
|
+
* argv and its reply parser, the argv echo check, herdr's error code and the conditional reclaim.
|
|
24
|
+
* The fresh tab-create BUILDER is not reused, because it carries the fresh callback env by contract
|
|
25
|
+
* and a resume has no callback, no nonce, no task, no prompt and no model of its own — the runtime
|
|
26
|
+
* argv is the record's, built on the v2 side. Borrowing that builder would mean faking a callback for
|
|
27
|
+
* a sibling nobody is waiting on. No other fresh-specific stage is reused either: no birth framing
|
|
28
|
+
* and no settle witness.
|
|
29
|
+
*
|
|
30
|
+
* THE ORDER IS THE FRESH RAIL'S, FOR THE SAME REASON. `tab create` and `agent start` are two
|
|
31
|
+
* mutations, so everything decidable is decided before the first: context, backend, cwd, control
|
|
32
|
+
* characters in the runtime argv, and the read-only question of which workspace the caller is in.
|
|
33
|
+
* Every failure after the tab create carries a recovery that says what is out there; none of them
|
|
34
|
+
* says that nothing was opened.
|
|
35
|
+
*
|
|
36
|
+
* WHAT THE RECEIPT IS NOT. A successful `agent start` is a LAUNCH: herdr made a tab and was asked to
|
|
37
|
+
* start pi on that transcript there. herdr's readiness and its `agent_session` are herdr's view of
|
|
38
|
+
* the pane, not garden addressability, so this module does not wait for either. The same-gid control
|
|
39
|
+
* socket the v2 side waits for afterwards is the only observation that says the citizen is back.
|
|
40
|
+
*
|
|
41
|
+
* PI-DURABLE IS A SECOND, SEPARATE LAUNCH (#157 B3). herdr has no agent kind for the durable app, so
|
|
42
|
+
* its same-id resume reuses the fresh rail's ONE `layout.apply` on the caller's own herdr socket
|
|
43
|
+
* (`herdrPiDurableResumeCall` below) rather than `tab create` + `agent start`. The pi path above is
|
|
44
|
+
* untouched and still refuses every non-pi backend; `launchHerdrResume` is the one place that tells
|
|
45
|
+
* the three apart, by the backend the v2 side resolved from the record.
|
|
46
|
+
*
|
|
47
|
+
* CLAUDE CODE IS A THIRD, SEPARATE LAUNCH (#157 stage 4): the same ONE layout.apply, then ONE
|
|
48
|
+
* `pane.process_info` read on the pane it created, so the v2 side can join the receiver marker's owner
|
|
49
|
+
* to that pane's direct child exactly as it joins the tmux pane pid.
|
|
50
|
+
*/
|
|
51
|
+
import { spawn as spawnChildProcess } from "node:child_process";
|
|
52
|
+
import { randomBytes } from "node:crypto";
|
|
53
|
+
import { argvMatchesRequest, boundHerdrDiagnostic, buildHerdrAgentStartArgs, buildHerdrLayoutApplyPacket, buildHerdrPaneGetArgs, buildHerdrPaneGetPacket, classifyHerdrCwd, classifyHerdrLayoutApplyReply, classifyHerdrLayoutPaneRead, containsControlChar, createHerdrRunner, HERDR_AGENT_KIND, HERDR_LAYOUT_PRODUCTION_DEPS, HERDR_REJECT_HINT, parseHerdrAgentStartResponse, parseHerdrPaneGetResponse, parseHerdrTabCreateResponse, readHerdrEnvelope, readHerdrErrorCode, reclaim, resolveHerdrContext, resolveHerdrSocketPath, resolvePiDurableLauncher, } from "./herdr-fresh-call.js";
|
|
54
|
+
/**
|
|
55
|
+
* The agent name herdr will know this launch by: `entwurf-resume-` plus 16 random hex, 31 characters.
|
|
56
|
+
* `[file:line @ c77af189]` `src/app/agents.rs:15-20` requires `[a-z][a-z0-9_-]{0,31}`. It is random
|
|
57
|
+
* per call, not derived from the session or the citizen: this module never learns either, and a name
|
|
58
|
+
* an operator could read an address into is the promotion this rail refuses.
|
|
59
|
+
*/
|
|
60
|
+
export function mintHerdrResumeAgentName() {
|
|
61
|
+
return `entwurf-resume-${randomBytes(8).toString("hex")}`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* `tab create` for a resume: the fresh rail's placement policy — a NEW tab in the caller's own
|
|
65
|
+
* workspace, named explicitly, without taking focus — and its fixed identity scrub, with NO callback
|
|
66
|
+
* env. The scrub is the reason the tmux resume leaf carries one too: a pi that inherits a stale
|
|
67
|
+
* `PI_SESSION_ID` would attach as the citizen that env names instead of the one its transcript
|
|
68
|
+
* belongs to. `--cwd` is always present because a resume always has the record's directory.
|
|
69
|
+
*/
|
|
70
|
+
export function buildHerdrResumeTabCreateArgs(params) {
|
|
71
|
+
return [
|
|
72
|
+
"tab",
|
|
73
|
+
"create",
|
|
74
|
+
"--workspace",
|
|
75
|
+
params.workspaceId,
|
|
76
|
+
"--no-focus",
|
|
77
|
+
"--cwd",
|
|
78
|
+
params.cwd,
|
|
79
|
+
"--env",
|
|
80
|
+
"PI_SESSION_ID=",
|
|
81
|
+
"--env",
|
|
82
|
+
"PI_AGENT_ID=",
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Open pi on the record's transcript in a new herdr tab.
|
|
87
|
+
*
|
|
88
|
+
* `makeRun` receives the herdr binary the context names and returns the one way this call talks to
|
|
89
|
+
* herdr; production passes `createHerdrRunner`, a test passes a table. It is called only after the
|
|
90
|
+
* context resolved, so a refusal for a missing context never builds a runner.
|
|
91
|
+
*/
|
|
92
|
+
export async function herdrResumeCall(params, makeRun, env, agentName = mintHerdrResumeAgentName()) {
|
|
93
|
+
const context = resolveHerdrContext(env);
|
|
94
|
+
if (!context.ok) {
|
|
95
|
+
if (context.reason === "herdr-context-missing" || context.reason === "herdr-caller-pane-missing") {
|
|
96
|
+
return { ok: false, reason: context.reason };
|
|
97
|
+
}
|
|
98
|
+
// Crash, don't warn: a context refusal this rail has no word for is drift in the shared
|
|
99
|
+
// primitive, not something to rename into the nearest reason.
|
|
100
|
+
throw new Error(`herdr-resume-call: the herdr context refused as ${context.reason}, which this rail does not name`);
|
|
101
|
+
}
|
|
102
|
+
// Pi only, before anything is read or created — see the module header.
|
|
103
|
+
if (params.backend !== "pi")
|
|
104
|
+
return { ok: false, reason: "herdr-resume-backend-unsupported" };
|
|
105
|
+
const badCwd = classifyHerdrCwd(params.cwd);
|
|
106
|
+
if (badCwd !== null)
|
|
107
|
+
return { ok: false, reason: badCwd };
|
|
108
|
+
// herdr refuses ANY control character in an agent argument; it is checked while it is still free.
|
|
109
|
+
if (params.runtimeArgs.some(containsControlChar))
|
|
110
|
+
return { ok: false, reason: "herdr-argv-control-character" };
|
|
111
|
+
const run = makeRun(context.context.bin);
|
|
112
|
+
// Which workspace is the caller in? herdr's own answer about the caller's own pane, read and bound
|
|
113
|
+
// exactly as the fresh rail binds it. A read creates nothing, so every failure here is a refusal.
|
|
114
|
+
const callerPaneRun = await run(buildHerdrPaneGetArgs(context.context.callerPaneId));
|
|
115
|
+
if (callerPaneRun.status !== 0)
|
|
116
|
+
return { ok: false, reason: "herdr-caller-pane-get-failed" };
|
|
117
|
+
const callerPane = parseHerdrPaneGetResponse(callerPaneRun.stdout);
|
|
118
|
+
if (callerPane === null)
|
|
119
|
+
return { ok: false, reason: "herdr-caller-pane-unparsable" };
|
|
120
|
+
if (callerPane.paneId !== context.context.callerPaneId)
|
|
121
|
+
return { ok: false, reason: "herdr-caller-pane-drift" };
|
|
122
|
+
if (callerPane.workspaceId === undefined)
|
|
123
|
+
return { ok: false, reason: "herdr-caller-workspace-missing" };
|
|
124
|
+
// ── everything above this line leaves nothing behind ──────────────────────────────────
|
|
125
|
+
const tabRun = await run(buildHerdrResumeTabCreateArgs({ workspaceId: callerPane.workspaceId, cwd: params.cwd }));
|
|
126
|
+
if (tabRun.status !== 0) {
|
|
127
|
+
// herdr's own envelope means herdr answered and declined before making a tab; no envelope
|
|
128
|
+
// means our runner's own timeout/kill/spawn failure, after which a tab may well exist.
|
|
129
|
+
const code = readHerdrErrorCode(tabRun.stderr);
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
reason: "herdr-tab-create-failed",
|
|
133
|
+
...(code === undefined ? {} : { herdrErrorCode: code }),
|
|
134
|
+
recovery: code === undefined ? { outcome: "unknown" } : { outcome: "none" },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const tab = parseHerdrTabCreateResponse(tabRun.stdout);
|
|
138
|
+
if (tab === null)
|
|
139
|
+
return { ok: false, reason: "herdr-tab-create-unparsable", recovery: { outcome: "unknown" } };
|
|
140
|
+
const pane = tab.rootPane;
|
|
141
|
+
// Every coordinate below comes from the reply that CREATED the tab: it is the one the reclaim is
|
|
142
|
+
// bound to, so the coordinates an operator reads and the pane this rail may close are the same.
|
|
143
|
+
const created = {
|
|
144
|
+
herdrWorkspaceId: tab.workspaceId,
|
|
145
|
+
herdrTabId: tab.tabId,
|
|
146
|
+
herdrPaneId: pane.paneId,
|
|
147
|
+
herdrTerminalId: pane.terminalId,
|
|
148
|
+
};
|
|
149
|
+
// The tab must be where this call ASKED for it: the caller's own workspace. The shared parser
|
|
150
|
+
// proves the reply agrees with itself (tab and root pane name one workspace), not that it agrees
|
|
151
|
+
// with the request — a coherent reply about another workspace would start pi where the operator
|
|
152
|
+
// is not looking, under a receipt that reads as a launch. So it is a named failure, reclaimed.
|
|
153
|
+
if (tab.workspaceId !== callerPane.workspaceId) {
|
|
154
|
+
return { ok: false, reason: "herdr-tab-create-workspace-drift", recovery: await reclaim(pane, run), created };
|
|
155
|
+
}
|
|
156
|
+
// Occupancy is TWO facts, as the shared reclaim reads them (`decideConditionalClose`): herdr naming
|
|
157
|
+
// an agent in the pane, and herdr holding that agent's session. An agent whose session has not been
|
|
158
|
+
// reported yet is still somebody in the pane, so either fact stops the start.
|
|
159
|
+
if (pane.agent !== undefined || pane.hasAgentSession) {
|
|
160
|
+
return { ok: false, reason: "herdr-tab-root-pane-occupied", recovery: await reclaim(pane, run), created };
|
|
161
|
+
}
|
|
162
|
+
const startRun = await run(buildHerdrAgentStartArgs({
|
|
163
|
+
agentName,
|
|
164
|
+
kind: HERDR_AGENT_KIND.pi,
|
|
165
|
+
paneId: pane.paneId,
|
|
166
|
+
backendArgs: params.runtimeArgs,
|
|
167
|
+
}));
|
|
168
|
+
if (startRun.status !== 0) {
|
|
169
|
+
const code = readHerdrErrorCode(startRun.stderr);
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
reason: "herdr-agent-start-failed",
|
|
173
|
+
...(code === undefined ? {} : { herdrErrorCode: code }),
|
|
174
|
+
recovery: await reclaim(pane, run),
|
|
175
|
+
created,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const started = parseHerdrAgentStartResponse(startRun.stdout);
|
|
179
|
+
if (started === null) {
|
|
180
|
+
return { ok: false, reason: "herdr-agent-start-unparsable", recovery: await reclaim(pane, run), created };
|
|
181
|
+
}
|
|
182
|
+
if (started.pane.paneId !== pane.paneId ||
|
|
183
|
+
started.pane.terminalId !== pane.terminalId ||
|
|
184
|
+
(started.pane.tabId !== undefined && started.pane.tabId !== tab.tabId)) {
|
|
185
|
+
return { ok: false, reason: "herdr-agent-start-pane-drift", recovery: await reclaim(pane, run), created };
|
|
186
|
+
}
|
|
187
|
+
// The argv IS the record's transcript, provider and model. A pi started with anything else is a
|
|
188
|
+
// citizen this call did not ask for, and nothing downstream would reveal it.
|
|
189
|
+
if (!argvMatchesRequest(started.argv, "pi", params.runtimeArgs)) {
|
|
190
|
+
return { ok: false, reason: "herdr-agent-start-argv-drift", recovery: await reclaim(pane, run), created };
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
ok: true,
|
|
194
|
+
receipt: { ...created, requestedKind: HERDR_AGENT_KIND.pi, cwd: params.cwd, herdrAgentName: agentName },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/** Why each outcome happened, in the caller's terms. Code-owned like every herdr rail hint; root
|
|
198
|
+
* ENTWURF.json does not carry them. */
|
|
199
|
+
export const HERDR_RESUME_HINT = {
|
|
200
|
+
"herdr-context-missing": "HERDR_ENV/HERDR_BIN_PATH are absent, so this process cannot reach herdr and this rail does not exist here; nothing was sent to tmux instead",
|
|
201
|
+
"herdr-caller-pane-missing": "herdr did not give this process a HERDR_PANE_ID, so there is no way to ask herdr which workspace this caller is in",
|
|
202
|
+
"herdr-caller-pane-get-failed": "herdr would not report this process's own pane — run `herdr pane get $HERDR_PANE_ID` to see what it says",
|
|
203
|
+
"herdr-caller-pane-unparsable": "herdr's reply about this process's own pane could not be read",
|
|
204
|
+
"herdr-caller-pane-drift": "herdr answered about a different pane than HERDR_PANE_ID names, so its workspace says nothing about where this caller is",
|
|
205
|
+
"herdr-caller-workspace-missing": "herdr reported this process's pane with no workspace, and guessing one would reopen the citizen wherever the operator is looking",
|
|
206
|
+
"herdr-resume-backend-unsupported": "inside herdr, same-id resume reopens pi, pi-durable and Claude Code; this agent-start path carries pi alone, and nothing was sent to tmux instead",
|
|
207
|
+
"herdr-argv-control-character": "the recorded runtime argv holds a control character, which herdr refuses in an agent argument",
|
|
208
|
+
"cwd-not-absolute": "the recorded start directory is not an absolute path",
|
|
209
|
+
"cwd-missing": "the recorded start directory no longer exists",
|
|
210
|
+
"cwd-not-directory": "the recorded start path is not a directory",
|
|
211
|
+
"herdr-tab-create-failed": "the tab create did not succeed — read the recovery line for whether anything exists",
|
|
212
|
+
"herdr-tab-create-unparsable": "herdr's tab reply could not be read, so a tab may exist that this call cannot name",
|
|
213
|
+
"herdr-tab-create-workspace-drift": "herdr created the tab in a different workspace than the caller's own one this call asked for, so pi was not started there",
|
|
214
|
+
"herdr-tab-root-pane-occupied": "the new tab's initial pane already holds an agent, so pi was not started into it",
|
|
215
|
+
"herdr-agent-start-failed": "herdr did not report pi as started in the tab that was just created",
|
|
216
|
+
"herdr-agent-start-unparsable": "herdr's start reply could not be read",
|
|
217
|
+
"herdr-agent-start-pane-drift": "herdr reported a different pane, terminal or tab than the one it just created",
|
|
218
|
+
"herdr-agent-start-argv-drift": "herdr echoed an argv that is not the recorded one this call asked it to start",
|
|
219
|
+
};
|
|
220
|
+
export function herdrResumeLaunchOutcome(result) {
|
|
221
|
+
if (result.ok)
|
|
222
|
+
return { ok: true, handle: { rail: "herdr", ...result.receipt } };
|
|
223
|
+
const hint = HERDR_RESUME_HINT[result.reason];
|
|
224
|
+
if (!("recovery" in result))
|
|
225
|
+
return { ok: false, reason: result.reason, hint };
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
reason: result.reason,
|
|
229
|
+
hint,
|
|
230
|
+
failure: {
|
|
231
|
+
rail: "herdr",
|
|
232
|
+
...(result.herdrErrorCode === undefined ? {} : { vendorCode: result.herdrErrorCode }),
|
|
233
|
+
recovery: result.recovery,
|
|
234
|
+
...(result.created === undefined ? {} : { created: result.created }),
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/** The production herdr launch the shared rail root (`dispatchResumeLaunch`) returns to the v2 seam
|
|
239
|
+
* when this process is inside herdr. A pi-durable resume (#157 B3) and a Claude Code resume (#157
|
|
240
|
+
* stage 4) are the layout launches below; every other backend goes to the pi path, which refuses all
|
|
241
|
+
* but pi. */
|
|
242
|
+
export async function launchHerdrResume(input, env, spawn = spawnChildProcess) {
|
|
243
|
+
if (input.backend === "pi-durable") {
|
|
244
|
+
if (input.piDurableAdoptEnv === undefined) {
|
|
245
|
+
throw new Error("herdr-resume-call: a pi-durable resume reached the herdr rail without its adopt projection");
|
|
246
|
+
}
|
|
247
|
+
return herdrPiDurableResumeLaunchOutcome(await herdrPiDurableResumeCall({ cwd: input.cwd, runtimeArgs: input.runtimeArgs, adoptEnv: input.piDurableAdoptEnv }, env));
|
|
248
|
+
}
|
|
249
|
+
if (input.backend === "claude-code") {
|
|
250
|
+
return herdrClaudeCodeResumeLaunchOutcome(await herdrClaudeCodeResumeCall({ cwd: input.cwd, runtimeArgs: input.runtimeArgs }, env));
|
|
251
|
+
}
|
|
252
|
+
return herdrResumeLaunchOutcome(await herdrResumeCall(input, (bin) => createHerdrRunner(bin, env, spawn), env));
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Open the reserved pi-durable session in a new herdr tab: the verified launcher, the record-derived
|
|
256
|
+
* argv (the managed verb and its closed adopt context, built on the v2 side and passed through
|
|
257
|
+
* untouched), the reservation's cwd, and an env map of exactly the identity scrub plus the five adopt
|
|
258
|
+
* names. `deps` is the fresh layout branch's own seam; `requestBase` names this call's two request ids.
|
|
259
|
+
*/
|
|
260
|
+
export async function herdrPiDurableResumeCall(params, env, deps = HERDR_LAYOUT_PRODUCTION_DEPS, requestBase = mintHerdrResumeAgentName()) {
|
|
261
|
+
const herdr = resolveHerdrContext(env);
|
|
262
|
+
if (!herdr.ok) {
|
|
263
|
+
if (herdr.reason === "herdr-context-missing" || herdr.reason === "herdr-caller-pane-missing") {
|
|
264
|
+
return { ok: false, reason: herdr.reason };
|
|
265
|
+
}
|
|
266
|
+
throw new Error(`herdr-resume-call: the herdr context refused as ${herdr.reason}, which this rail does not name`);
|
|
267
|
+
}
|
|
268
|
+
const badCwd = classifyHerdrCwd(params.cwd);
|
|
269
|
+
if (badCwd !== null)
|
|
270
|
+
return { ok: false, reason: badCwd };
|
|
271
|
+
const socketPath = resolveHerdrSocketPath(env);
|
|
272
|
+
if (socketPath === null)
|
|
273
|
+
return { ok: false, reason: "herdr-socket-path-missing" };
|
|
274
|
+
const layout = deps.packageLayout();
|
|
275
|
+
if (layout === null)
|
|
276
|
+
return { ok: false, reason: "pi-durable-package-incomplete" };
|
|
277
|
+
const runtimeRefusal = deps.runtimeVerdict(layout);
|
|
278
|
+
if (runtimeRefusal !== null)
|
|
279
|
+
return { ok: false, reason: runtimeRefusal };
|
|
280
|
+
const runtime = resolvePiDurableLauncher(layout);
|
|
281
|
+
if (runtime === null)
|
|
282
|
+
return { ok: false, reason: "pi-durable-launcher-missing" };
|
|
283
|
+
const command = [runtime, ...params.runtimeArgs];
|
|
284
|
+
const launchEnv = {
|
|
285
|
+
PI_SESSION_ID: "",
|
|
286
|
+
PI_AGENT_ID: "",
|
|
287
|
+
PI_CODING_AGENT_DIR: params.adoptEnv.PI_CODING_AGENT_DIR,
|
|
288
|
+
ENTWURF_META_SESSIONS_DIR: params.adoptEnv.ENTWURF_META_SESSIONS_DIR,
|
|
289
|
+
ENTWURF_META_MAILBOX_DIR: params.adoptEnv.ENTWURF_META_MAILBOX_DIR,
|
|
290
|
+
ENTWURF_META_SENDERS_DIR: params.adoptEnv.ENTWURF_META_SENDERS_DIR,
|
|
291
|
+
ENTWURF_META_RECEIVERS_DIR: params.adoptEnv.ENTWURF_META_RECEIVERS_DIR,
|
|
292
|
+
};
|
|
293
|
+
// A control character anywhere in the vector or the env map is refused while it is still free:
|
|
294
|
+
// this exec path has no Cc rule of its own, and herdr's env check would answer only after the write.
|
|
295
|
+
if (command.some(containsControlChar) || Object.values(launchEnv).some(containsControlChar)) {
|
|
296
|
+
return { ok: false, reason: "herdr-argv-control-character" };
|
|
297
|
+
}
|
|
298
|
+
const call = deps.socket(socketPath);
|
|
299
|
+
const readId = `${requestBase}:pane.get`;
|
|
300
|
+
const read = classifyHerdrLayoutPaneRead(await call(buildHerdrPaneGetPacket(readId, herdr.context.callerPaneId)), readId, herdr.context.callerPaneId);
|
|
301
|
+
if (!read.ok)
|
|
302
|
+
return { ok: false, reason: read.reason, reported: read.reported };
|
|
303
|
+
// ── everything above this line leaves nothing behind; the ONE layout mutation write follows ──
|
|
304
|
+
const writeId = `${requestBase}:layout.apply`;
|
|
305
|
+
const applied = classifyHerdrLayoutApplyReply(await call(buildHerdrLayoutApplyPacket(writeId, {
|
|
306
|
+
workspaceId: read.workspaceId,
|
|
307
|
+
cwd: params.cwd,
|
|
308
|
+
command,
|
|
309
|
+
env: launchEnv,
|
|
310
|
+
})), { id: writeId, workspaceId: read.workspaceId, cwd: params.cwd, command });
|
|
311
|
+
if (!applied.ok)
|
|
312
|
+
return applied;
|
|
313
|
+
return {
|
|
314
|
+
ok: true,
|
|
315
|
+
receipt: {
|
|
316
|
+
runtime,
|
|
317
|
+
cwd: params.cwd,
|
|
318
|
+
herdrWorkspaceId: applied.workspaceId,
|
|
319
|
+
herdrTabId: applied.tabId,
|
|
320
|
+
herdrPaneId: applied.paneId,
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/** Why each pi-durable outcome happened. The context and caller-pane words are this verb's own (the
|
|
325
|
+
* pi resume table); the package, launcher, endpoint and layout words are the fresh layout branch's. */
|
|
326
|
+
export const HERDR_PI_DURABLE_RESUME_HINT = {
|
|
327
|
+
"herdr-context-missing": HERDR_RESUME_HINT["herdr-context-missing"],
|
|
328
|
+
"herdr-caller-pane-missing": HERDR_RESUME_HINT["herdr-caller-pane-missing"],
|
|
329
|
+
"cwd-not-absolute": HERDR_RESUME_HINT["cwd-not-absolute"],
|
|
330
|
+
"cwd-missing": HERDR_RESUME_HINT["cwd-missing"],
|
|
331
|
+
"cwd-not-directory": HERDR_RESUME_HINT["cwd-not-directory"],
|
|
332
|
+
"herdr-socket-path-missing": HERDR_REJECT_HINT["herdr-socket-path-missing"],
|
|
333
|
+
"pi-durable-package-incomplete": HERDR_REJECT_HINT["pi-durable-package-incomplete"],
|
|
334
|
+
"pi-durable-carrier-drift": HERDR_REJECT_HINT["pi-durable-carrier-drift"],
|
|
335
|
+
"pi-durable-sdk-absent": HERDR_REJECT_HINT["pi-durable-sdk-absent"],
|
|
336
|
+
"pi-durable-sdk-mismatch": HERDR_REJECT_HINT["pi-durable-sdk-mismatch"],
|
|
337
|
+
"pi-durable-launcher-missing": HERDR_REJECT_HINT["pi-durable-launcher-missing"],
|
|
338
|
+
"herdr-argv-control-character": "the reservation's launch argv or adopt env holds a control character, which this rail refuses before asking herdr anything",
|
|
339
|
+
"herdr-caller-pane-get-failed": "herdr would not report this process's own pane through one `pane.get` on HERDR_SOCKET_PATH; any cause herdr reported is quoted",
|
|
340
|
+
"herdr-caller-pane-unparsable": HERDR_RESUME_HINT["herdr-caller-pane-unparsable"],
|
|
341
|
+
"herdr-caller-pane-drift": HERDR_RESUME_HINT["herdr-caller-pane-drift"],
|
|
342
|
+
"herdr-caller-workspace-missing": HERDR_RESUME_HINT["herdr-caller-workspace-missing"],
|
|
343
|
+
"herdr-layout-transport-failed": HERDR_REJECT_HINT["herdr-layout-transport-failed"],
|
|
344
|
+
"herdr-layout-apply-refused": HERDR_REJECT_HINT["herdr-layout-apply-refused"],
|
|
345
|
+
"herdr-layout-apply-failed": HERDR_REJECT_HINT["herdr-layout-apply-failed"],
|
|
346
|
+
"herdr-layout-reply-unparsable": HERDR_REJECT_HINT["herdr-layout-reply-unparsable"],
|
|
347
|
+
"herdr-layout-reply-drift": HERDR_REJECT_HINT["herdr-layout-reply-drift"],
|
|
348
|
+
};
|
|
349
|
+
/** The reported diagnostics, already cut, quoted onto the hint so a value cannot pose as prose. */
|
|
350
|
+
function reportedSuffix(reported) {
|
|
351
|
+
if (reported === undefined)
|
|
352
|
+
return "";
|
|
353
|
+
const all = [
|
|
354
|
+
["transport", reported.transport],
|
|
355
|
+
["code", reported.errorCode],
|
|
356
|
+
["message", reported.errorMessage],
|
|
357
|
+
["workspace", reported.workspaceId],
|
|
358
|
+
["tab", reported.tabId],
|
|
359
|
+
["pane", reported.paneId],
|
|
360
|
+
];
|
|
361
|
+
const fields = all.filter((field) => field[1] !== undefined);
|
|
362
|
+
if (fields.length === 0)
|
|
363
|
+
return "";
|
|
364
|
+
return ` (reported, display only and unverified: ${fields.map(([name, value]) => `${name} ${JSON.stringify(value)}`).join(", ")})`;
|
|
365
|
+
}
|
|
366
|
+
/** The pi-durable layout result as the v2 seam reads it: a `herdr-layout` handle, a refusal, or a
|
|
367
|
+
* failure carrying the layout's own `none | unknown` certainty — no created coordinates, none known. */
|
|
368
|
+
export function herdrPiDurableResumeLaunchOutcome(result) {
|
|
369
|
+
if (result.ok)
|
|
370
|
+
return { ok: true, handle: { rail: "herdr-layout", ...result.receipt } };
|
|
371
|
+
const hint = `${HERDR_PI_DURABLE_RESUME_HINT[result.reason]}${reportedSuffix(result.reported)}`;
|
|
372
|
+
if ("recovery" in result) {
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
reason: result.reason,
|
|
376
|
+
hint,
|
|
377
|
+
failure: {
|
|
378
|
+
rail: "herdr-layout",
|
|
379
|
+
...(result.herdrErrorCode === undefined ? {} : { vendorCode: result.herdrErrorCode }),
|
|
380
|
+
recovery: result.recovery,
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return { ok: false, reason: result.reason, hint };
|
|
385
|
+
}
|
|
386
|
+
// ── #157 stage 4: the Claude Code same-id resume through ONE layout.apply ───────────────────────────
|
|
387
|
+
//
|
|
388
|
+
// A Claude Code resume is observed by joining the receiver marker its Entwurf hook re-arms to the
|
|
389
|
+
// process this launch started: the hook names its parent, the Claude Code process, as the marker's
|
|
390
|
+
// owner. `agent start` types the command into the pane's interactive shell, so that process is the
|
|
391
|
+
// shell's child and no herdr coordinate names it. A layout argv pane spawns the vector's program as
|
|
392
|
+
// the pane's direct child (`[측정 S4-CC-A H1, 2026-11-02, herdr 0.9.5 at the pinned aarch64 digest]`:
|
|
393
|
+
// a program's self-reported pid, written before one exec, equalled `pane.process_info` `shell_pid`
|
|
394
|
+
// and its foreground pid, at the same kernel start time, with the herdr server as its parent). So
|
|
395
|
+
// this branch is the pi-durable layout launch with the record's argv and no adopt env, followed by ONE
|
|
396
|
+
// read of that report for the pane it created. The read mutates nothing; whatever it says, the tab
|
|
397
|
+
// stays as it is — this branch closes nothing, as the pi-durable one closes nothing.
|
|
398
|
+
//
|
|
399
|
+
// The runtime is the bare name `claude`, resolved by herdr exactly as its `agent start` resolves the
|
|
400
|
+
// fresh Claude Code launch: this module imports no tmux leaf, so it carries no PATH resolver, and the
|
|
401
|
+
// receipt says herdr resolved it. Which binary ran is not what the observation proves; the join does.
|
|
402
|
+
/** What herdr is asked to run first in a Claude Code resume's layout vector. */
|
|
403
|
+
export const HERDR_CLAUDE_CODE_RESUME_RUNTIME = "claude";
|
|
404
|
+
/** The fixed process read. Product function, not an RPC surface: method and shape are literal. */
|
|
405
|
+
export function buildHerdrPaneProcessInfoPacket(id, paneId) {
|
|
406
|
+
return `${JSON.stringify({ id, method: "pane.process_info", params: { pane_id: paneId } })}\n`;
|
|
407
|
+
}
|
|
408
|
+
function isRecord(value) {
|
|
409
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The process read, reduced to the pane's direct-child pid. A success must name THIS pane and carry
|
|
413
|
+
* `shell_pid` (herdr's name for the pane runtime's direct child, `integer | null` in its bundled
|
|
414
|
+
* schema) as an integer above 1; anything else is `unread` with its cause — a pane whose process
|
|
415
|
+
* already exited reports null, and that is not a pid to join.
|
|
416
|
+
*/
|
|
417
|
+
export function classifyHerdrPaneProcessInfo(outcome, id, paneId) {
|
|
418
|
+
if (outcome.kind !== "reply") {
|
|
419
|
+
return { unread: boundHerdrDiagnostic("code" in outcome ? `${outcome.kind}: ${outcome.code}` : outcome.kind) };
|
|
420
|
+
}
|
|
421
|
+
const envelope = readHerdrEnvelope(outcome.line, id);
|
|
422
|
+
if (envelope === null)
|
|
423
|
+
return { unread: "herdr's process reply could not be read" };
|
|
424
|
+
if (envelope.arm === "error")
|
|
425
|
+
return { unread: boundHerdrDiagnostic(`herdr error ${envelope.code}: ${envelope.message}`) };
|
|
426
|
+
const info = envelope.result.process_info;
|
|
427
|
+
if (envelope.result.type !== "pane_process_info" || !isRecord(info) || info.pane_id !== paneId) {
|
|
428
|
+
return { unread: "herdr's process reply does not describe the pane this call created" };
|
|
429
|
+
}
|
|
430
|
+
const pid = info.shell_pid;
|
|
431
|
+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 1) {
|
|
432
|
+
return { unread: `herdr reported no live direct child for the pane (shell_pid ${JSON.stringify(pid ?? null)})` };
|
|
433
|
+
}
|
|
434
|
+
return { pid: String(pid) };
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Open the dormant Claude Code session in a new herdr tab: `claude` plus the record-derived argv
|
|
438
|
+
* (`--resume <session id>`, built on the v2 side and passed through untouched), the record's cwd, and
|
|
439
|
+
* the identity scrub as the env map — then read which process the new pane runs.
|
|
440
|
+
*/
|
|
441
|
+
export async function herdrClaudeCodeResumeCall(params, env, deps = HERDR_LAYOUT_PRODUCTION_DEPS, requestBase = mintHerdrResumeAgentName()) {
|
|
442
|
+
const herdr = resolveHerdrContext(env);
|
|
443
|
+
if (!herdr.ok) {
|
|
444
|
+
if (herdr.reason === "herdr-context-missing" || herdr.reason === "herdr-caller-pane-missing") {
|
|
445
|
+
return { ok: false, reason: herdr.reason };
|
|
446
|
+
}
|
|
447
|
+
throw new Error(`herdr-resume-call: the herdr context refused as ${herdr.reason}, which this rail does not name`);
|
|
448
|
+
}
|
|
449
|
+
const badCwd = classifyHerdrCwd(params.cwd);
|
|
450
|
+
if (badCwd !== null)
|
|
451
|
+
return { ok: false, reason: badCwd };
|
|
452
|
+
const socketPath = resolveHerdrSocketPath(env);
|
|
453
|
+
if (socketPath === null)
|
|
454
|
+
return { ok: false, reason: "herdr-socket-path-missing" };
|
|
455
|
+
const command = [HERDR_CLAUDE_CODE_RESUME_RUNTIME, ...params.runtimeArgs];
|
|
456
|
+
const launchEnv = { PI_SESSION_ID: "", PI_AGENT_ID: "" };
|
|
457
|
+
if (command.some(containsControlChar))
|
|
458
|
+
return { ok: false, reason: "herdr-argv-control-character" };
|
|
459
|
+
const call = deps.socket(socketPath);
|
|
460
|
+
const readId = `${requestBase}:pane.get`;
|
|
461
|
+
const read = classifyHerdrLayoutPaneRead(await call(buildHerdrPaneGetPacket(readId, herdr.context.callerPaneId)), readId, herdr.context.callerPaneId);
|
|
462
|
+
if (!read.ok)
|
|
463
|
+
return { ok: false, reason: read.reason, reported: read.reported };
|
|
464
|
+
// ── everything above this line leaves nothing behind; the ONE layout mutation write follows ──
|
|
465
|
+
const writeId = `${requestBase}:layout.apply`;
|
|
466
|
+
const applied = classifyHerdrLayoutApplyReply(await call(buildHerdrLayoutApplyPacket(writeId, { workspaceId: read.workspaceId, cwd: params.cwd, command, env: launchEnv })), { id: writeId, workspaceId: read.workspaceId, cwd: params.cwd, command });
|
|
467
|
+
if (!applied.ok)
|
|
468
|
+
return applied;
|
|
469
|
+
// The tab exists from here on. ONE read of what the pane this call created is running; whatever it
|
|
470
|
+
// says is reported, and nothing is closed or retried.
|
|
471
|
+
const { workspaceId, tabId, paneId } = applied;
|
|
472
|
+
const processId = `${requestBase}:pane.process_info`;
|
|
473
|
+
const paneChild = classifyHerdrPaneProcessInfo(await call(buildHerdrPaneProcessInfoPacket(processId, paneId)), processId, paneId);
|
|
474
|
+
const receipt = {
|
|
475
|
+
runtime: HERDR_CLAUDE_CODE_RESUME_RUNTIME,
|
|
476
|
+
cwd: params.cwd,
|
|
477
|
+
herdrWorkspaceId: workspaceId,
|
|
478
|
+
herdrTabId: tabId,
|
|
479
|
+
herdrPaneId: paneId,
|
|
480
|
+
paneChild,
|
|
481
|
+
};
|
|
482
|
+
return { ok: true, receipt };
|
|
483
|
+
}
|
|
484
|
+
/** The one shared word whose fresh-rail hint names pi-durable: for this launch it names Claude Code. */
|
|
485
|
+
export const HERDR_CLAUDE_CODE_SOCKET_PATH_HINT = "HERDR_SOCKET_PATH is absent, empty or not absolute, so there is no herdr endpoint for Claude Code's one layout request — this rail does not guess or scan for one, and nothing was sent to tmux instead";
|
|
486
|
+
/** The Claude Code layout result as the v2 seam reads it: a `herdr-layout-claude` handle, or — every
|
|
487
|
+
* refusal and layout failure being one the pi-durable layout branch also names — that branch's own
|
|
488
|
+
* mapping, so the two layout launches cannot classify one herdr fact two ways. The one word whose hint
|
|
489
|
+
* names pi-durable is reworded for Claude Code; every other hint names no backend. */
|
|
490
|
+
export function herdrClaudeCodeResumeLaunchOutcome(result) {
|
|
491
|
+
if (result.ok)
|
|
492
|
+
return { ok: true, handle: { rail: "herdr-layout-claude", ...result.receipt } };
|
|
493
|
+
const shared = herdrPiDurableResumeLaunchOutcome(result);
|
|
494
|
+
if (shared.ok || result.reason !== "herdr-socket-path-missing")
|
|
495
|
+
return shared;
|
|
496
|
+
return { ...shared, hint: HERDR_CLAUDE_CODE_SOCKET_PATH_HINT };
|
|
497
|
+
}
|
|
@@ -1228,7 +1228,9 @@ export function defaultMetaReceiversDir() {
|
|
|
1228
1228
|
* sandboxed receivers root sandboxes it too and no new environment carrier exists. It is
|
|
1229
1229
|
* deliberately NOT the v2 dispatch lock dir — visible resume holds that per-gid lock across
|
|
1230
1230
|
* its whole launch + observation window, and the relaunched session's own arm must not wait
|
|
1231
|
-
* on it.
|
|
1231
|
+
* on it. The Claude Code hook and the pi-durable birth (`pi-durable-location.ts`, #157 B3 C4)
|
|
1232
|
+
* take this lock, each around the upsert that mints `attachIncarnation`; nothing else reads or
|
|
1233
|
+
* writes here.
|
|
1232
1234
|
*/
|
|
1233
1235
|
export function defaultMetaRecordArmLocksDir(receiversDir = defaultMetaReceiversDir()) {
|
|
1234
1236
|
return path.join(path.dirname(receiversDir), "meta-record-arm-locks");
|
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
* This is the tmux rail. A caller inside herdr (`HERDR_ENV=1`, herdr's own marker, read for its
|
|
24
24
|
* exact value as `fresh-call-dispatch` does) is refused by name before anything else: herdr owns
|
|
25
25
|
* placement there, and an ambient `TMUX` that happens to be set would otherwise put the window in
|
|
26
|
-
* a session the operator is not looking at.
|
|
26
|
+
* a session the operator is not looking at. Since #157 G1a the shared rail root (`fresh-call-dispatch`)
|
|
27
|
+
* hands a resume inside herdr to the separate herdr rail (`herdr-resume-call.ts`) and never reaches
|
|
28
|
+
* this leaf there;
|
|
29
|
+
* this guard stays so that a direct caller still cannot open a tmux window from inside herdr. No
|
|
30
|
+
* fallback in either direction.
|
|
27
31
|
*
|
|
28
32
|
* ── The cwd is the whole reason this module owns its own tmux argv ──
|
|
29
33
|
*
|
|
@@ -56,24 +60,53 @@ import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWin
|
|
|
56
60
|
* backend boundary is enforced upstream by `resolveResumeLaunchIdentity`, and this is the mux half
|
|
57
61
|
* of the same fact. */
|
|
58
62
|
export const RESUME_CALL_RUNTIME = { pi: "pi", "claude-code": "claude" };
|
|
63
|
+
/**
|
|
64
|
+
* #157 B3: a pi-durable resume reopens through the MANAGED verb, exactly as its fresh call does —
|
|
65
|
+
* `entwurf pi-durable` verifies this package's carrier and its SDK set before anything runs, and the
|
|
66
|
+
* durable app is never started bare. It is its own entry (`resumePiDurableCall`) rather than a row of
|
|
67
|
+
* the table above, because it is the one resume whose launch also carries an environment.
|
|
68
|
+
*/
|
|
69
|
+
export const RESUME_CALL_PI_DURABLE_RUNTIME = "entwurf";
|
|
70
|
+
/** The five `NAME=value` assignments, in the fixed order, or none. Every value must be an absolute
|
|
71
|
+
* path: a relative one would resolve against whatever directory the child starts in. */
|
|
72
|
+
function adoptEnvAssignments(adoptEnv) {
|
|
73
|
+
if (adoptEnv === undefined)
|
|
74
|
+
return [];
|
|
75
|
+
const pairs = [
|
|
76
|
+
["PI_CODING_AGENT_DIR", adoptEnv.PI_CODING_AGENT_DIR],
|
|
77
|
+
["ENTWURF_META_SESSIONS_DIR", adoptEnv.ENTWURF_META_SESSIONS_DIR],
|
|
78
|
+
["ENTWURF_META_MAILBOX_DIR", adoptEnv.ENTWURF_META_MAILBOX_DIR],
|
|
79
|
+
["ENTWURF_META_SENDERS_DIR", adoptEnv.ENTWURF_META_SENDERS_DIR],
|
|
80
|
+
["ENTWURF_META_RECEIVERS_DIR", adoptEnv.ENTWURF_META_RECEIVERS_DIR],
|
|
81
|
+
];
|
|
82
|
+
for (const [name, value] of pairs) {
|
|
83
|
+
if (typeof value !== "string" || !value.startsWith("/")) {
|
|
84
|
+
throw new Error(`mux-resume-call: refusing to build argv with a non-absolute ${name}: ${JSON.stringify(value)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return pairs.map(([name, value]) => `${name}=${value}`);
|
|
88
|
+
}
|
|
59
89
|
/**
|
|
60
90
|
* Launch argv: the leaf's detached-append shape, the fixed identity scrub (`SCRUBBED_INHERITED_ENV`,
|
|
61
|
-
* the same two assignments every fresh window carries),
|
|
91
|
+
* the same two assignments every fresh window carries), for a pi-durable adoption the five fixed
|
|
92
|
+
* projection assignments after it (#157 B3), `-c`, the runtime, then the caller's flags.
|
|
62
93
|
* `--` is what keeps tmux from reading the runtime or its flags as tmux options. The scrub is not a
|
|
63
94
|
* carrier: it only empties identity a tmux server may have inherited, and a resumed Claude Code
|
|
64
95
|
* (which, unlike pi, never re-exports its own) would otherwise present a stale one to its bridge.
|
|
65
96
|
*/
|
|
66
|
-
export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
|
|
97
|
+
export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs, adoptEnv) {
|
|
67
98
|
assertSelector("session", placement.sessionId);
|
|
68
99
|
assertLaunchTarget(runtimePath);
|
|
69
100
|
const bad = classifyTmuxCwd(cwd);
|
|
70
101
|
if (bad)
|
|
71
102
|
throw new Error(`mux-resume-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
|
|
103
|
+
const adoptAssignments = adoptEnvAssignments(adoptEnv);
|
|
72
104
|
return [
|
|
73
105
|
"new-window",
|
|
74
106
|
"-d",
|
|
75
107
|
"-a",
|
|
76
108
|
...SCRUBBED_INHERITED_ENV.flatMap((assignment) => ["-e", assignment]),
|
|
109
|
+
...adoptAssignments.flatMap((assignment) => ["-e", assignment]),
|
|
77
110
|
"-t",
|
|
78
111
|
`${placement.sessionId}:{end}`,
|
|
79
112
|
"-c",
|
|
@@ -94,6 +127,18 @@ export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
|
|
|
94
127
|
* `runtimeArgs` is passed through untouched. This module does not know what `--session` means.
|
|
95
128
|
*/
|
|
96
129
|
export function resumeCall(params, env = process.env) {
|
|
130
|
+
return openResumeWindow(RESUME_CALL_RUNTIME[params.backend], params, undefined, env);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* #157 B3: the pi-durable resume window. The same order, refusals and receipt as `resumeCall` — the
|
|
134
|
+
* runtime is the managed `entwurf`, and the window additionally carries the fixed adopt projection.
|
|
135
|
+
* `runtimeArgs` (the managed verb and its closed adopt context) is passed through untouched; this
|
|
136
|
+
* module never decodes it.
|
|
137
|
+
*/
|
|
138
|
+
export function resumePiDurableCall(params, env = process.env) {
|
|
139
|
+
return openResumeWindow(RESUME_CALL_PI_DURABLE_RUNTIME, params, params.adoptEnv, env);
|
|
140
|
+
}
|
|
141
|
+
function openResumeWindow(runtime, params, adoptEnv, env) {
|
|
97
142
|
if (env.HERDR_ENV === "1")
|
|
98
143
|
return { ok: false, reason: "herdr-resume-unsupported" };
|
|
99
144
|
const badCwd = classifyTmuxCwd(params.cwd);
|
|
@@ -101,7 +146,7 @@ export function resumeCall(params, env = process.env) {
|
|
|
101
146
|
return { ok: false, reason: badCwd };
|
|
102
147
|
let runtimePath;
|
|
103
148
|
try {
|
|
104
|
-
runtimePath = resolveRuntimeOnPath(
|
|
149
|
+
runtimePath = resolveRuntimeOnPath(runtime, env);
|
|
105
150
|
}
|
|
106
151
|
catch (err) {
|
|
107
152
|
if (err instanceof LaunchPreconditionError)
|
|
@@ -113,7 +158,7 @@ export function resumeCall(params, env = process.env) {
|
|
|
113
158
|
return { ok: false, reason: inspected.reason };
|
|
114
159
|
const placement = inspected.placement;
|
|
115
160
|
requireSameContext("resumeCall", placement, env);
|
|
116
|
-
const run = runTmux(buildResumeCallArgs(placement, params.cwd, runtimePath, params.runtimeArgs), env);
|
|
161
|
+
const run = runTmux(buildResumeCallArgs(placement, params.cwd, runtimePath, params.runtimeArgs, adoptEnv), env);
|
|
117
162
|
assertTmuxOk("new-window", run);
|
|
118
163
|
let fields;
|
|
119
164
|
try {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* `{v:2,task}` payload, whose decoder caps `task` at 16000 characters. A framed
|
|
20
20
|
* task over that cap would open a window whose bootstrap refuses its own argv.
|
|
21
21
|
*
|
|
22
|
-
* ── Then the carrier, then the
|
|
22
|
+
* ── Then the carrier, then the five units ──
|
|
23
23
|
*
|
|
24
24
|
* carrier the package's own pi-durable carrier and its declared SDK set, verified
|
|
25
25
|
* (`pi-durable-runtime.ts`); passed in as that check's verdict.
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
* it every dispatch to the citizen is the honest `mailbox-undeliverable`.
|
|
30
30
|
* visible identity the packaged bootstrap — the one that hands the TUI the view whose conversation
|
|
31
31
|
* labels carry the garden id.
|
|
32
|
+
* owner fence the compiled owner fence in the same closure — the packaged bootstrap imports it,
|
|
33
|
+
* so without it the bootstrap cannot load. Asked here as a file this package must
|
|
34
|
+
* ship, nothing more; the carrier verdict above does not cover it.
|
|
32
35
|
*
|
|
33
36
|
* ── What this deliberately does NOT claim ──
|
|
34
37
|
*
|
|
@@ -78,6 +81,8 @@ export function piDurableFreshPreflight(params) {
|
|
|
78
81
|
return "pi-durable-receive-unit-missing";
|
|
79
82
|
if (!existsSync(layout.bootstrap))
|
|
80
83
|
return "pi-durable-visible-identity-missing";
|
|
84
|
+
if (!existsSync(layout.ownerFence))
|
|
85
|
+
return "pi-durable-owner-fence-missing";
|
|
81
86
|
return null;
|
|
82
87
|
}
|
|
83
88
|
/** Repair text beside the predicate that decides it. The runtime reasons keep theirs on the runtime
|
|
@@ -89,4 +94,5 @@ export const PI_DURABLE_PREFLIGHT_HINT = {
|
|
|
89
94
|
"pi-durable-mcp-hand-missing": "this Entwurf package has no compiled entwurf-bridge entry, so the sibling's callback tool would not exist — reinstall or rebuild Entwurf",
|
|
90
95
|
"pi-durable-receive-unit-missing": "this Entwurf package's compiled owner join does not admit pi-durable-host, so nothing could ever be delivered to the sibling — reinstall or rebuild Entwurf",
|
|
91
96
|
"pi-durable-visible-identity-missing": "this Entwurf package has no packaged pi-durable bootstrap (pi/pi-durable/bootstrap.mjs), so no TUI would carry the garden id — reinstall Entwurf",
|
|
97
|
+
"pi-durable-owner-fence-missing": "this Entwurf package has no compiled pi-durable owner fence (mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-owner-fence.js), which its packaged bootstrap imports — reinstall or rebuild Entwurf",
|
|
92
98
|
};
|