@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,810 @@
|
|
|
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
|
+
|
|
52
|
+
import { spawn as spawnChildProcess } from "node:child_process";
|
|
53
|
+
import { randomBytes } from "node:crypto";
|
|
54
|
+
import {
|
|
55
|
+
argvMatchesRequest,
|
|
56
|
+
boundHerdrDiagnostic,
|
|
57
|
+
buildHerdrAgentStartArgs,
|
|
58
|
+
buildHerdrLayoutApplyPacket,
|
|
59
|
+
buildHerdrPaneGetArgs,
|
|
60
|
+
buildHerdrPaneGetPacket,
|
|
61
|
+
classifyHerdrCwd,
|
|
62
|
+
classifyHerdrLayoutApplyReply,
|
|
63
|
+
classifyHerdrLayoutPaneRead,
|
|
64
|
+
containsControlChar,
|
|
65
|
+
createHerdrRunner,
|
|
66
|
+
HERDR_AGENT_KIND,
|
|
67
|
+
HERDR_LAYOUT_PRODUCTION_DEPS,
|
|
68
|
+
HERDR_REJECT_HINT,
|
|
69
|
+
type HerdrFreshCallRejectReason,
|
|
70
|
+
type HerdrLaunchFailureReason,
|
|
71
|
+
type HerdrLayoutDeps,
|
|
72
|
+
type HerdrLayoutFailure,
|
|
73
|
+
type HerdrLayoutFailureReason,
|
|
74
|
+
type HerdrRecovery,
|
|
75
|
+
type HerdrReportedDiagnostics,
|
|
76
|
+
type HerdrRun,
|
|
77
|
+
type HerdrSocketOutcome,
|
|
78
|
+
parseHerdrAgentStartResponse,
|
|
79
|
+
parseHerdrPaneGetResponse,
|
|
80
|
+
parseHerdrTabCreateResponse,
|
|
81
|
+
readHerdrEnvelope,
|
|
82
|
+
readHerdrErrorCode,
|
|
83
|
+
reclaim,
|
|
84
|
+
resolveHerdrContext,
|
|
85
|
+
resolveHerdrSocketPath,
|
|
86
|
+
resolvePiDurableLauncher,
|
|
87
|
+
type SpawnFn,
|
|
88
|
+
} from "./herdr-fresh-call.ts";
|
|
89
|
+
|
|
90
|
+
/** Why a herdr resume launch was refused BEFORE anything was created. The context, caller-pane and
|
|
91
|
+
* cwd words are the fresh rail's own, so an operator learns one word per herdr fact. */
|
|
92
|
+
export type HerdrResumeRejectReason =
|
|
93
|
+
| Extract<
|
|
94
|
+
HerdrFreshCallRejectReason,
|
|
95
|
+
| "herdr-context-missing"
|
|
96
|
+
| "herdr-caller-pane-missing"
|
|
97
|
+
| "herdr-caller-pane-get-failed"
|
|
98
|
+
| "herdr-caller-pane-unparsable"
|
|
99
|
+
| "herdr-caller-pane-drift"
|
|
100
|
+
| "herdr-caller-workspace-missing"
|
|
101
|
+
| "herdr-argv-control-character"
|
|
102
|
+
| "cwd-not-absolute"
|
|
103
|
+
| "cwd-missing"
|
|
104
|
+
| "cwd-not-directory"
|
|
105
|
+
>
|
|
106
|
+
/** Not the fresh rail's `herdr-backend-unsupported`: that word's hint promises pi AND claude-code,
|
|
107
|
+
* and on this verb the herdr agent-start path opens pi only (pi-durable and Claude Code take their own layout branches). */
|
|
108
|
+
| "herdr-resume-backend-unsupported";
|
|
109
|
+
|
|
110
|
+
/** Why a launch that had already reached the tab create failed. The fresh rail's words, minus
|
|
111
|
+
* `herdr-agent-start-vanished`, which names a settle witness this rail does not run, plus one word of
|
|
112
|
+
* this rail's own: `herdr-tab-create-workspace-drift`, a create reply that agrees with itself but
|
|
113
|
+
* names another workspace than the caller's one this call asked for. */
|
|
114
|
+
export type HerdrResumeLaunchFailureReason =
|
|
115
|
+
| Exclude<HerdrLaunchFailureReason, "herdr-agent-start-vanished">
|
|
116
|
+
| "herdr-tab-create-workspace-drift";
|
|
117
|
+
|
|
118
|
+
/** herdr's coordinates for the tab this call created — known only once the create reply was read.
|
|
119
|
+
* VIEWS, not addresses: `[측정 2026-09-14]` a pane id is not stable for the life of a sibling. */
|
|
120
|
+
export interface HerdrResumeCreated {
|
|
121
|
+
readonly herdrWorkspaceId: string;
|
|
122
|
+
readonly herdrTabId: string;
|
|
123
|
+
/** The new tab's INITIAL pane — where pi was started, and the only thing this rail may close. */
|
|
124
|
+
readonly herdrPaneId: string;
|
|
125
|
+
readonly herdrTerminalId: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The launch receipt: what herdr was asked to start, at which requested directory, and where the view
|
|
130
|
+
* is. ABSENT BY CONTRACT, as on the fresh rail: any garden or native session id (the address is the
|
|
131
|
+
* record's, and the v2 side already holds it), screen text, `agent_status`, `interactive_ready` and
|
|
132
|
+
* `agent_session`. There is no witness field because there is no settle stage.
|
|
133
|
+
*/
|
|
134
|
+
export interface HerdrResumeReceipt extends HerdrResumeCreated {
|
|
135
|
+
/** The kind herdr was ASKED for — not proof that the runtime exists (see `HERDR_AGENT_KIND`). */
|
|
136
|
+
readonly requestedKind: string;
|
|
137
|
+
/** The REQUESTED start directory, the record's — not an observation of where the pane landed. */
|
|
138
|
+
readonly cwd: string;
|
|
139
|
+
readonly herdrAgentName: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type HerdrResumeCallResult =
|
|
143
|
+
| { readonly ok: true; readonly receipt: HerdrResumeReceipt }
|
|
144
|
+
| { readonly ok: false; readonly reason: HerdrResumeRejectReason }
|
|
145
|
+
| {
|
|
146
|
+
readonly ok: false;
|
|
147
|
+
readonly reason: HerdrResumeLaunchFailureReason;
|
|
148
|
+
/** herdr's own error code when it gave one — quoted, never retyped. */
|
|
149
|
+
readonly herdrErrorCode?: string;
|
|
150
|
+
readonly recovery: HerdrRecovery;
|
|
151
|
+
/** Present exactly when the create reply was read, so the operator is told WHICH tab. */
|
|
152
|
+
readonly created?: HerdrResumeCreated;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The agent name herdr will know this launch by: `entwurf-resume-` plus 16 random hex, 31 characters.
|
|
157
|
+
* `[file:line @ c77af189]` `src/app/agents.rs:15-20` requires `[a-z][a-z0-9_-]{0,31}`. It is random
|
|
158
|
+
* per call, not derived from the session or the citizen: this module never learns either, and a name
|
|
159
|
+
* an operator could read an address into is the promotion this rail refuses.
|
|
160
|
+
*/
|
|
161
|
+
export function mintHerdrResumeAgentName(): string {
|
|
162
|
+
return `entwurf-resume-${randomBytes(8).toString("hex")}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `tab create` for a resume: the fresh rail's placement policy — a NEW tab in the caller's own
|
|
167
|
+
* workspace, named explicitly, without taking focus — and its fixed identity scrub, with NO callback
|
|
168
|
+
* env. The scrub is the reason the tmux resume leaf carries one too: a pi that inherits a stale
|
|
169
|
+
* `PI_SESSION_ID` would attach as the citizen that env names instead of the one its transcript
|
|
170
|
+
* belongs to. `--cwd` is always present because a resume always has the record's directory.
|
|
171
|
+
*/
|
|
172
|
+
export function buildHerdrResumeTabCreateArgs(params: { workspaceId: string; cwd: string }): string[] {
|
|
173
|
+
return [
|
|
174
|
+
"tab",
|
|
175
|
+
"create",
|
|
176
|
+
"--workspace",
|
|
177
|
+
params.workspaceId,
|
|
178
|
+
"--no-focus",
|
|
179
|
+
"--cwd",
|
|
180
|
+
params.cwd,
|
|
181
|
+
"--env",
|
|
182
|
+
"PI_SESSION_ID=",
|
|
183
|
+
"--env",
|
|
184
|
+
"PI_AGENT_ID=",
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Open pi on the record's transcript in a new herdr tab.
|
|
190
|
+
*
|
|
191
|
+
* `makeRun` receives the herdr binary the context names and returns the one way this call talks to
|
|
192
|
+
* herdr; production passes `createHerdrRunner`, a test passes a table. It is called only after the
|
|
193
|
+
* context resolved, so a refusal for a missing context never builds a runner.
|
|
194
|
+
*/
|
|
195
|
+
export async function herdrResumeCall(
|
|
196
|
+
params: { readonly backend: string; readonly cwd: string; readonly runtimeArgs: readonly string[] },
|
|
197
|
+
makeRun: (bin: string) => HerdrRun,
|
|
198
|
+
env: NodeJS.ProcessEnv,
|
|
199
|
+
agentName: string = mintHerdrResumeAgentName(),
|
|
200
|
+
): Promise<HerdrResumeCallResult> {
|
|
201
|
+
const context = resolveHerdrContext(env);
|
|
202
|
+
if (!context.ok) {
|
|
203
|
+
if (context.reason === "herdr-context-missing" || context.reason === "herdr-caller-pane-missing") {
|
|
204
|
+
return { ok: false, reason: context.reason };
|
|
205
|
+
}
|
|
206
|
+
// Crash, don't warn: a context refusal this rail has no word for is drift in the shared
|
|
207
|
+
// primitive, not something to rename into the nearest reason.
|
|
208
|
+
throw new Error(`herdr-resume-call: the herdr context refused as ${context.reason}, which this rail does not name`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Pi only, before anything is read or created — see the module header.
|
|
212
|
+
if (params.backend !== "pi") return { ok: false, reason: "herdr-resume-backend-unsupported" };
|
|
213
|
+
|
|
214
|
+
const badCwd = classifyHerdrCwd(params.cwd);
|
|
215
|
+
if (badCwd !== null) return { ok: false, reason: badCwd };
|
|
216
|
+
// herdr refuses ANY control character in an agent argument; it is checked while it is still free.
|
|
217
|
+
if (params.runtimeArgs.some(containsControlChar)) return { ok: false, reason: "herdr-argv-control-character" };
|
|
218
|
+
|
|
219
|
+
const run = makeRun(context.context.bin);
|
|
220
|
+
// Which workspace is the caller in? herdr's own answer about the caller's own pane, read and bound
|
|
221
|
+
// exactly as the fresh rail binds it. A read creates nothing, so every failure here is a refusal.
|
|
222
|
+
const callerPaneRun = await run(buildHerdrPaneGetArgs(context.context.callerPaneId));
|
|
223
|
+
if (callerPaneRun.status !== 0) return { ok: false, reason: "herdr-caller-pane-get-failed" };
|
|
224
|
+
const callerPane = parseHerdrPaneGetResponse(callerPaneRun.stdout);
|
|
225
|
+
if (callerPane === null) return { ok: false, reason: "herdr-caller-pane-unparsable" };
|
|
226
|
+
if (callerPane.paneId !== context.context.callerPaneId) return { ok: false, reason: "herdr-caller-pane-drift" };
|
|
227
|
+
if (callerPane.workspaceId === undefined) return { ok: false, reason: "herdr-caller-workspace-missing" };
|
|
228
|
+
|
|
229
|
+
// ── everything above this line leaves nothing behind ──────────────────────────────────
|
|
230
|
+
const tabRun = await run(buildHerdrResumeTabCreateArgs({ workspaceId: callerPane.workspaceId, cwd: params.cwd }));
|
|
231
|
+
if (tabRun.status !== 0) {
|
|
232
|
+
// herdr's own envelope means herdr answered and declined before making a tab; no envelope
|
|
233
|
+
// means our runner's own timeout/kill/spawn failure, after which a tab may well exist.
|
|
234
|
+
const code = readHerdrErrorCode(tabRun.stderr);
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
reason: "herdr-tab-create-failed",
|
|
238
|
+
...(code === undefined ? {} : { herdrErrorCode: code }),
|
|
239
|
+
recovery: code === undefined ? { outcome: "unknown" } : { outcome: "none" },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const tab = parseHerdrTabCreateResponse(tabRun.stdout);
|
|
243
|
+
if (tab === null) return { ok: false, reason: "herdr-tab-create-unparsable", recovery: { outcome: "unknown" } };
|
|
244
|
+
const pane = tab.rootPane;
|
|
245
|
+
// Every coordinate below comes from the reply that CREATED the tab: it is the one the reclaim is
|
|
246
|
+
// bound to, so the coordinates an operator reads and the pane this rail may close are the same.
|
|
247
|
+
const created: HerdrResumeCreated = {
|
|
248
|
+
herdrWorkspaceId: tab.workspaceId,
|
|
249
|
+
herdrTabId: tab.tabId,
|
|
250
|
+
herdrPaneId: pane.paneId,
|
|
251
|
+
herdrTerminalId: pane.terminalId,
|
|
252
|
+
};
|
|
253
|
+
// The tab must be where this call ASKED for it: the caller's own workspace. The shared parser
|
|
254
|
+
// proves the reply agrees with itself (tab and root pane name one workspace), not that it agrees
|
|
255
|
+
// with the request — a coherent reply about another workspace would start pi where the operator
|
|
256
|
+
// is not looking, under a receipt that reads as a launch. So it is a named failure, reclaimed.
|
|
257
|
+
if (tab.workspaceId !== callerPane.workspaceId) {
|
|
258
|
+
return { ok: false, reason: "herdr-tab-create-workspace-drift", recovery: await reclaim(pane, run), created };
|
|
259
|
+
}
|
|
260
|
+
// Occupancy is TWO facts, as the shared reclaim reads them (`decideConditionalClose`): herdr naming
|
|
261
|
+
// an agent in the pane, and herdr holding that agent's session. An agent whose session has not been
|
|
262
|
+
// reported yet is still somebody in the pane, so either fact stops the start.
|
|
263
|
+
if (pane.agent !== undefined || pane.hasAgentSession) {
|
|
264
|
+
return { ok: false, reason: "herdr-tab-root-pane-occupied", recovery: await reclaim(pane, run), created };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const startRun = await run(
|
|
268
|
+
buildHerdrAgentStartArgs({
|
|
269
|
+
agentName,
|
|
270
|
+
kind: HERDR_AGENT_KIND.pi,
|
|
271
|
+
paneId: pane.paneId,
|
|
272
|
+
backendArgs: params.runtimeArgs,
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
if (startRun.status !== 0) {
|
|
276
|
+
const code = readHerdrErrorCode(startRun.stderr);
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
reason: "herdr-agent-start-failed",
|
|
280
|
+
...(code === undefined ? {} : { herdrErrorCode: code }),
|
|
281
|
+
recovery: await reclaim(pane, run),
|
|
282
|
+
created,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const started = parseHerdrAgentStartResponse(startRun.stdout);
|
|
286
|
+
if (started === null) {
|
|
287
|
+
return { ok: false, reason: "herdr-agent-start-unparsable", recovery: await reclaim(pane, run), created };
|
|
288
|
+
}
|
|
289
|
+
if (
|
|
290
|
+
started.pane.paneId !== pane.paneId ||
|
|
291
|
+
started.pane.terminalId !== pane.terminalId ||
|
|
292
|
+
(started.pane.tabId !== undefined && started.pane.tabId !== tab.tabId)
|
|
293
|
+
) {
|
|
294
|
+
return { ok: false, reason: "herdr-agent-start-pane-drift", recovery: await reclaim(pane, run), created };
|
|
295
|
+
}
|
|
296
|
+
// The argv IS the record's transcript, provider and model. A pi started with anything else is a
|
|
297
|
+
// citizen this call did not ask for, and nothing downstream would reveal it.
|
|
298
|
+
if (!argvMatchesRequest(started.argv, "pi", params.runtimeArgs)) {
|
|
299
|
+
return { ok: false, reason: "herdr-agent-start-argv-drift", recovery: await reclaim(pane, run), created };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
receipt: { ...created, requestedKind: HERDR_AGENT_KIND.pi, cwd: params.cwd, herdrAgentName: agentName },
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Why each outcome happened, in the caller's terms. Code-owned like every herdr rail hint; root
|
|
309
|
+
* ENTWURF.json does not carry them. */
|
|
310
|
+
export const HERDR_RESUME_HINT: Readonly<Record<HerdrResumeRejectReason | HerdrResumeLaunchFailureReason, string>> = {
|
|
311
|
+
"herdr-context-missing":
|
|
312
|
+
"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",
|
|
313
|
+
"herdr-caller-pane-missing":
|
|
314
|
+
"herdr did not give this process a HERDR_PANE_ID, so there is no way to ask herdr which workspace this caller is in",
|
|
315
|
+
"herdr-caller-pane-get-failed":
|
|
316
|
+
"herdr would not report this process's own pane — run `herdr pane get $HERDR_PANE_ID` to see what it says",
|
|
317
|
+
"herdr-caller-pane-unparsable": "herdr's reply about this process's own pane could not be read",
|
|
318
|
+
"herdr-caller-pane-drift":
|
|
319
|
+
"herdr answered about a different pane than HERDR_PANE_ID names, so its workspace says nothing about where this caller is",
|
|
320
|
+
"herdr-caller-workspace-missing":
|
|
321
|
+
"herdr reported this process's pane with no workspace, and guessing one would reopen the citizen wherever the operator is looking",
|
|
322
|
+
"herdr-resume-backend-unsupported":
|
|
323
|
+
"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",
|
|
324
|
+
"herdr-argv-control-character":
|
|
325
|
+
"the recorded runtime argv holds a control character, which herdr refuses in an agent argument",
|
|
326
|
+
"cwd-not-absolute": "the recorded start directory is not an absolute path",
|
|
327
|
+
"cwd-missing": "the recorded start directory no longer exists",
|
|
328
|
+
"cwd-not-directory": "the recorded start path is not a directory",
|
|
329
|
+
"herdr-tab-create-failed": "the tab create did not succeed — read the recovery line for whether anything exists",
|
|
330
|
+
"herdr-tab-create-unparsable": "herdr's tab reply could not be read, so a tab may exist that this call cannot name",
|
|
331
|
+
"herdr-tab-create-workspace-drift":
|
|
332
|
+
"herdr created the tab in a different workspace than the caller's own one this call asked for, so pi was not started there",
|
|
333
|
+
"herdr-tab-root-pane-occupied": "the new tab's initial pane already holds an agent, so pi was not started into it",
|
|
334
|
+
"herdr-agent-start-failed": "herdr did not report pi as started in the tab that was just created",
|
|
335
|
+
"herdr-agent-start-unparsable": "herdr's start reply could not be read",
|
|
336
|
+
"herdr-agent-start-pane-drift": "herdr reported a different pane, terminal or tab than the one it just created",
|
|
337
|
+
"herdr-agent-start-argv-drift": "herdr echoed an argv that is not the recorded one this call asked it to start",
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* What the v2 launch seam receives. Structural on purpose, as the tmux leaf's own result is: naming
|
|
342
|
+
* v2's types here would import entwurf core into a herdr rail.
|
|
343
|
+
*
|
|
344
|
+
* ok a herdr handle — `rail: "herdr"` plus the receipt, nothing tmux-shaped invented
|
|
345
|
+
* reject reason + hint, and nothing was created
|
|
346
|
+
* failure reason + hint + what is out there: the rail, herdr's code, the recovery, the created tab;
|
|
347
|
+
* the pi-durable layout failure tags its rail `herdr-layout`, so no tab create step is named
|
|
348
|
+
*/
|
|
349
|
+
export type HerdrResumeLaunchOutcome =
|
|
350
|
+
| { readonly ok: true; readonly handle: { readonly rail: "herdr" } & HerdrResumeReceipt }
|
|
351
|
+
/** #157 B3: the pi-durable layout launch — its own rail tag, because it carries no agent name and no
|
|
352
|
+
* terminal id (herdr's layout reply names neither), and a pi resume must never read one as its own. */
|
|
353
|
+
| { readonly ok: true; readonly handle: { readonly rail: "herdr-layout" } & HerdrPiDurableResumeReceipt }
|
|
354
|
+
/** #157 stage 4: the Claude Code layout launch — its own rail tag, because it alone carries the pane's
|
|
355
|
+
* direct-child pid its observation joins, and neither other resume may read one as its own. */
|
|
356
|
+
| { readonly ok: true; readonly handle: { readonly rail: "herdr-layout-claude" } & HerdrClaudeCodeResumeReceipt }
|
|
357
|
+
| { readonly ok: false; readonly reason: string; readonly hint: string }
|
|
358
|
+
| {
|
|
359
|
+
readonly ok: false;
|
|
360
|
+
readonly reason: string;
|
|
361
|
+
readonly hint: string;
|
|
362
|
+
readonly failure: {
|
|
363
|
+
readonly rail: "herdr" | "herdr-layout";
|
|
364
|
+
readonly vendorCode?: string;
|
|
365
|
+
readonly recovery: HerdrRecovery;
|
|
366
|
+
readonly created?: HerdrResumeCreated;
|
|
367
|
+
};
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
export function herdrResumeLaunchOutcome(result: HerdrResumeCallResult): HerdrResumeLaunchOutcome {
|
|
371
|
+
if (result.ok) return { ok: true, handle: { rail: "herdr", ...result.receipt } };
|
|
372
|
+
const hint = HERDR_RESUME_HINT[result.reason];
|
|
373
|
+
if (!("recovery" in result)) return { ok: false, reason: result.reason, hint };
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
reason: result.reason,
|
|
377
|
+
hint,
|
|
378
|
+
failure: {
|
|
379
|
+
rail: "herdr",
|
|
380
|
+
...(result.herdrErrorCode === undefined ? {} : { vendorCode: result.herdrErrorCode }),
|
|
381
|
+
recovery: result.recovery,
|
|
382
|
+
...(result.created === undefined ? {} : { created: result.created }),
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The production herdr launch the shared rail root (`dispatchResumeLaunch`) returns to the v2 seam
|
|
388
|
+
* when this process is inside herdr. A pi-durable resume (#157 B3) and a Claude Code resume (#157
|
|
389
|
+
* stage 4) are the layout launches below; every other backend goes to the pi path, which refuses all
|
|
390
|
+
* but pi. */
|
|
391
|
+
export async function launchHerdrResume(
|
|
392
|
+
input: {
|
|
393
|
+
readonly backend: string;
|
|
394
|
+
readonly cwd: string;
|
|
395
|
+
readonly runtimeArgs: readonly string[];
|
|
396
|
+
readonly piDurableAdoptEnv?: HerdrPiDurableAdoptEnv;
|
|
397
|
+
},
|
|
398
|
+
env: NodeJS.ProcessEnv,
|
|
399
|
+
spawn: SpawnFn = spawnChildProcess as SpawnFn,
|
|
400
|
+
): Promise<HerdrResumeLaunchOutcome> {
|
|
401
|
+
if (input.backend === "pi-durable") {
|
|
402
|
+
if (input.piDurableAdoptEnv === undefined) {
|
|
403
|
+
throw new Error("herdr-resume-call: a pi-durable resume reached the herdr rail without its adopt projection");
|
|
404
|
+
}
|
|
405
|
+
return herdrPiDurableResumeLaunchOutcome(
|
|
406
|
+
await herdrPiDurableResumeCall(
|
|
407
|
+
{ cwd: input.cwd, runtimeArgs: input.runtimeArgs, adoptEnv: input.piDurableAdoptEnv },
|
|
408
|
+
env,
|
|
409
|
+
),
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
if (input.backend === "claude-code") {
|
|
413
|
+
return herdrClaudeCodeResumeLaunchOutcome(
|
|
414
|
+
await herdrClaudeCodeResumeCall({ cwd: input.cwd, runtimeArgs: input.runtimeArgs }, env),
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
return herdrResumeLaunchOutcome(await herdrResumeCall(input, (bin) => createHerdrRunner(bin, env, spawn), env));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── #157 B3: the pi-durable same-id resume through ONE layout.apply ─────────────────────────────────
|
|
421
|
+
//
|
|
422
|
+
// The fresh rail's layout branch, minus everything a resume does not have: no nonce, no callback env,
|
|
423
|
+
// no task, no model and no preflight of a first input. What it keeps is its order and its certainty
|
|
424
|
+
// rules — every decidable refusal before the first socket request, the caller-pane read (a write on
|
|
425
|
+
// the socket that mutates nothing) before the ONE layout mutation write, never resent, and no reclaim
|
|
426
|
+
// (herdr's layout reply names no terminal id, so this branch holds no authority to close what it
|
|
427
|
+
// opened). What it adds is the adopt launch projection as the layout's env map.
|
|
428
|
+
//
|
|
429
|
+
// WHAT "NOTHING WAS CREATED" MEANS HERE, NAMED FOR THIS LEAF (L3-A1 §6: no rail's mapping is borrowed
|
|
430
|
+
// for another). A refusal (`ok:false` with no `recovery`) is decided before the layout write, so
|
|
431
|
+
// nothing was asked of herdr. After the write, `recovery.outcome === "none"` is the shared layout
|
|
432
|
+
// classifier's own certainty — the request was never written (a connect failure) or herdr answered a
|
|
433
|
+
// well-formed error with a code its pinned handler returns before any creation — and every other
|
|
434
|
+
// outcome is `unknown`. The v2 side withdraws its reservation for the first two and keeps it otherwise.
|
|
435
|
+
|
|
436
|
+
/** The adopt launch projection, read by name: the same five names the tmux leaf emits. */
|
|
437
|
+
export interface HerdrPiDurableAdoptEnv {
|
|
438
|
+
readonly PI_CODING_AGENT_DIR: string;
|
|
439
|
+
readonly ENTWURF_META_SESSIONS_DIR: string;
|
|
440
|
+
readonly ENTWURF_META_MAILBOX_DIR: string;
|
|
441
|
+
readonly ENTWURF_META_SENDERS_DIR: string;
|
|
442
|
+
readonly ENTWURF_META_RECEIVERS_DIR: string;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Why a pi-durable herdr resume was refused BEFORE the layout write. One word per herdr fact, the
|
|
446
|
+
* fresh layout branch's own. */
|
|
447
|
+
export type HerdrPiDurableResumeRejectReason = Extract<
|
|
448
|
+
HerdrFreshCallRejectReason,
|
|
449
|
+
| "herdr-context-missing"
|
|
450
|
+
| "herdr-caller-pane-missing"
|
|
451
|
+
| "cwd-not-absolute"
|
|
452
|
+
| "cwd-missing"
|
|
453
|
+
| "cwd-not-directory"
|
|
454
|
+
| "herdr-socket-path-missing"
|
|
455
|
+
| "pi-durable-package-incomplete"
|
|
456
|
+
| "pi-durable-carrier-drift"
|
|
457
|
+
| "pi-durable-sdk-absent"
|
|
458
|
+
| "pi-durable-sdk-mismatch"
|
|
459
|
+
| "pi-durable-launcher-missing"
|
|
460
|
+
| "herdr-argv-control-character"
|
|
461
|
+
| "herdr-caller-pane-get-failed"
|
|
462
|
+
| "herdr-caller-pane-unparsable"
|
|
463
|
+
| "herdr-caller-pane-drift"
|
|
464
|
+
| "herdr-caller-workspace-missing"
|
|
465
|
+
>;
|
|
466
|
+
|
|
467
|
+
/** What herdr's layout reply proved for THIS launch: the launcher and directory asked for, and the
|
|
468
|
+
* view coordinates. No agent name, no terminal id, no garden or native session id. */
|
|
469
|
+
export interface HerdrPiDurableResumeReceipt {
|
|
470
|
+
/** The verified package's launcher, first in the layout's argv vector. */
|
|
471
|
+
readonly runtime: string;
|
|
472
|
+
/** The REQUESTED start directory, the reservation's — not an observation of where the pane landed. */
|
|
473
|
+
readonly cwd: string;
|
|
474
|
+
readonly herdrWorkspaceId: string;
|
|
475
|
+
readonly herdrTabId: string;
|
|
476
|
+
readonly herdrPaneId: string;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export type HerdrPiDurableResumeResult =
|
|
480
|
+
| { readonly ok: true; readonly receipt: HerdrPiDurableResumeReceipt }
|
|
481
|
+
| {
|
|
482
|
+
readonly ok: false;
|
|
483
|
+
readonly reason: HerdrPiDurableResumeRejectReason;
|
|
484
|
+
readonly reported?: HerdrReportedDiagnostics;
|
|
485
|
+
}
|
|
486
|
+
| HerdrLayoutFailure;
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Open the reserved pi-durable session in a new herdr tab: the verified launcher, the record-derived
|
|
490
|
+
* argv (the managed verb and its closed adopt context, built on the v2 side and passed through
|
|
491
|
+
* untouched), the reservation's cwd, and an env map of exactly the identity scrub plus the five adopt
|
|
492
|
+
* names. `deps` is the fresh layout branch's own seam; `requestBase` names this call's two request ids.
|
|
493
|
+
*/
|
|
494
|
+
export async function herdrPiDurableResumeCall(
|
|
495
|
+
params: { readonly cwd: string; readonly runtimeArgs: readonly string[]; readonly adoptEnv: HerdrPiDurableAdoptEnv },
|
|
496
|
+
env: NodeJS.ProcessEnv,
|
|
497
|
+
deps: HerdrLayoutDeps = HERDR_LAYOUT_PRODUCTION_DEPS,
|
|
498
|
+
requestBase: string = mintHerdrResumeAgentName(),
|
|
499
|
+
): Promise<HerdrPiDurableResumeResult> {
|
|
500
|
+
const herdr = resolveHerdrContext(env);
|
|
501
|
+
if (!herdr.ok) {
|
|
502
|
+
if (herdr.reason === "herdr-context-missing" || herdr.reason === "herdr-caller-pane-missing") {
|
|
503
|
+
return { ok: false, reason: herdr.reason };
|
|
504
|
+
}
|
|
505
|
+
throw new Error(`herdr-resume-call: the herdr context refused as ${herdr.reason}, which this rail does not name`);
|
|
506
|
+
}
|
|
507
|
+
const badCwd = classifyHerdrCwd(params.cwd);
|
|
508
|
+
if (badCwd !== null) return { ok: false, reason: badCwd };
|
|
509
|
+
const socketPath = resolveHerdrSocketPath(env);
|
|
510
|
+
if (socketPath === null) return { ok: false, reason: "herdr-socket-path-missing" };
|
|
511
|
+
const layout = deps.packageLayout();
|
|
512
|
+
if (layout === null) return { ok: false, reason: "pi-durable-package-incomplete" };
|
|
513
|
+
const runtimeRefusal = deps.runtimeVerdict(layout);
|
|
514
|
+
if (runtimeRefusal !== null) return { ok: false, reason: runtimeRefusal };
|
|
515
|
+
const runtime = resolvePiDurableLauncher(layout);
|
|
516
|
+
if (runtime === null) return { ok: false, reason: "pi-durable-launcher-missing" };
|
|
517
|
+
|
|
518
|
+
const command = [runtime, ...params.runtimeArgs];
|
|
519
|
+
const launchEnv: Record<string, string> = {
|
|
520
|
+
PI_SESSION_ID: "",
|
|
521
|
+
PI_AGENT_ID: "",
|
|
522
|
+
PI_CODING_AGENT_DIR: params.adoptEnv.PI_CODING_AGENT_DIR,
|
|
523
|
+
ENTWURF_META_SESSIONS_DIR: params.adoptEnv.ENTWURF_META_SESSIONS_DIR,
|
|
524
|
+
ENTWURF_META_MAILBOX_DIR: params.adoptEnv.ENTWURF_META_MAILBOX_DIR,
|
|
525
|
+
ENTWURF_META_SENDERS_DIR: params.adoptEnv.ENTWURF_META_SENDERS_DIR,
|
|
526
|
+
ENTWURF_META_RECEIVERS_DIR: params.adoptEnv.ENTWURF_META_RECEIVERS_DIR,
|
|
527
|
+
};
|
|
528
|
+
// A control character anywhere in the vector or the env map is refused while it is still free:
|
|
529
|
+
// this exec path has no Cc rule of its own, and herdr's env check would answer only after the write.
|
|
530
|
+
if (command.some(containsControlChar) || Object.values(launchEnv).some(containsControlChar)) {
|
|
531
|
+
return { ok: false, reason: "herdr-argv-control-character" };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const call = deps.socket(socketPath);
|
|
535
|
+
const readId = `${requestBase}:pane.get`;
|
|
536
|
+
const read = classifyHerdrLayoutPaneRead(
|
|
537
|
+
await call(buildHerdrPaneGetPacket(readId, herdr.context.callerPaneId)),
|
|
538
|
+
readId,
|
|
539
|
+
herdr.context.callerPaneId,
|
|
540
|
+
);
|
|
541
|
+
if (!read.ok) return { ok: false, reason: read.reason, reported: read.reported };
|
|
542
|
+
|
|
543
|
+
// ── everything above this line leaves nothing behind; the ONE layout mutation write follows ──
|
|
544
|
+
const writeId = `${requestBase}:layout.apply`;
|
|
545
|
+
const applied = classifyHerdrLayoutApplyReply(
|
|
546
|
+
await call(
|
|
547
|
+
buildHerdrLayoutApplyPacket(writeId, {
|
|
548
|
+
workspaceId: read.workspaceId,
|
|
549
|
+
cwd: params.cwd,
|
|
550
|
+
command,
|
|
551
|
+
env: launchEnv,
|
|
552
|
+
}),
|
|
553
|
+
),
|
|
554
|
+
{ id: writeId, workspaceId: read.workspaceId, cwd: params.cwd, command },
|
|
555
|
+
);
|
|
556
|
+
if (!applied.ok) return applied;
|
|
557
|
+
return {
|
|
558
|
+
ok: true,
|
|
559
|
+
receipt: {
|
|
560
|
+
runtime,
|
|
561
|
+
cwd: params.cwd,
|
|
562
|
+
herdrWorkspaceId: applied.workspaceId,
|
|
563
|
+
herdrTabId: applied.tabId,
|
|
564
|
+
herdrPaneId: applied.paneId,
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Why each pi-durable outcome happened. The context and caller-pane words are this verb's own (the
|
|
570
|
+
* pi resume table); the package, launcher, endpoint and layout words are the fresh layout branch's. */
|
|
571
|
+
export const HERDR_PI_DURABLE_RESUME_HINT: Readonly<
|
|
572
|
+
Record<HerdrPiDurableResumeRejectReason | HerdrLayoutFailureReason, string>
|
|
573
|
+
> = {
|
|
574
|
+
"herdr-context-missing": HERDR_RESUME_HINT["herdr-context-missing"],
|
|
575
|
+
"herdr-caller-pane-missing": HERDR_RESUME_HINT["herdr-caller-pane-missing"],
|
|
576
|
+
"cwd-not-absolute": HERDR_RESUME_HINT["cwd-not-absolute"],
|
|
577
|
+
"cwd-missing": HERDR_RESUME_HINT["cwd-missing"],
|
|
578
|
+
"cwd-not-directory": HERDR_RESUME_HINT["cwd-not-directory"],
|
|
579
|
+
"herdr-socket-path-missing": HERDR_REJECT_HINT["herdr-socket-path-missing"],
|
|
580
|
+
"pi-durable-package-incomplete": HERDR_REJECT_HINT["pi-durable-package-incomplete"],
|
|
581
|
+
"pi-durable-carrier-drift": HERDR_REJECT_HINT["pi-durable-carrier-drift"],
|
|
582
|
+
"pi-durable-sdk-absent": HERDR_REJECT_HINT["pi-durable-sdk-absent"],
|
|
583
|
+
"pi-durable-sdk-mismatch": HERDR_REJECT_HINT["pi-durable-sdk-mismatch"],
|
|
584
|
+
"pi-durable-launcher-missing": HERDR_REJECT_HINT["pi-durable-launcher-missing"],
|
|
585
|
+
"herdr-argv-control-character":
|
|
586
|
+
"the reservation's launch argv or adopt env holds a control character, which this rail refuses before asking herdr anything",
|
|
587
|
+
"herdr-caller-pane-get-failed":
|
|
588
|
+
"herdr would not report this process's own pane through one `pane.get` on HERDR_SOCKET_PATH; any cause herdr reported is quoted",
|
|
589
|
+
"herdr-caller-pane-unparsable": HERDR_RESUME_HINT["herdr-caller-pane-unparsable"],
|
|
590
|
+
"herdr-caller-pane-drift": HERDR_RESUME_HINT["herdr-caller-pane-drift"],
|
|
591
|
+
"herdr-caller-workspace-missing": HERDR_RESUME_HINT["herdr-caller-workspace-missing"],
|
|
592
|
+
"herdr-layout-transport-failed": HERDR_REJECT_HINT["herdr-layout-transport-failed"],
|
|
593
|
+
"herdr-layout-apply-refused": HERDR_REJECT_HINT["herdr-layout-apply-refused"],
|
|
594
|
+
"herdr-layout-apply-failed": HERDR_REJECT_HINT["herdr-layout-apply-failed"],
|
|
595
|
+
"herdr-layout-reply-unparsable": HERDR_REJECT_HINT["herdr-layout-reply-unparsable"],
|
|
596
|
+
"herdr-layout-reply-drift": HERDR_REJECT_HINT["herdr-layout-reply-drift"],
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
/** The reported diagnostics, already cut, quoted onto the hint so a value cannot pose as prose. */
|
|
600
|
+
function reportedSuffix(reported: HerdrReportedDiagnostics | undefined): string {
|
|
601
|
+
if (reported === undefined) return "";
|
|
602
|
+
const all: ReadonlyArray<readonly [string, string | undefined]> = [
|
|
603
|
+
["transport", reported.transport],
|
|
604
|
+
["code", reported.errorCode],
|
|
605
|
+
["message", reported.errorMessage],
|
|
606
|
+
["workspace", reported.workspaceId],
|
|
607
|
+
["tab", reported.tabId],
|
|
608
|
+
["pane", reported.paneId],
|
|
609
|
+
];
|
|
610
|
+
const fields = all.filter((field): field is readonly [string, string] => field[1] !== undefined);
|
|
611
|
+
if (fields.length === 0) return "";
|
|
612
|
+
return ` (reported, display only and unverified: ${fields.map(([name, value]) => `${name} ${JSON.stringify(value)}`).join(", ")})`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** The pi-durable layout result as the v2 seam reads it: a `herdr-layout` handle, a refusal, or a
|
|
616
|
+
* failure carrying the layout's own `none | unknown` certainty — no created coordinates, none known. */
|
|
617
|
+
export function herdrPiDurableResumeLaunchOutcome(result: HerdrPiDurableResumeResult): HerdrResumeLaunchOutcome {
|
|
618
|
+
if (result.ok) return { ok: true, handle: { rail: "herdr-layout", ...result.receipt } };
|
|
619
|
+
const hint = `${HERDR_PI_DURABLE_RESUME_HINT[result.reason]}${reportedSuffix(result.reported)}`;
|
|
620
|
+
if ("recovery" in result) {
|
|
621
|
+
return {
|
|
622
|
+
ok: false,
|
|
623
|
+
reason: result.reason,
|
|
624
|
+
hint,
|
|
625
|
+
failure: {
|
|
626
|
+
rail: "herdr-layout",
|
|
627
|
+
...(result.herdrErrorCode === undefined ? {} : { vendorCode: result.herdrErrorCode }),
|
|
628
|
+
recovery: result.recovery,
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
return { ok: false, reason: result.reason, hint };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ── #157 stage 4: the Claude Code same-id resume through ONE layout.apply ───────────────────────────
|
|
636
|
+
//
|
|
637
|
+
// A Claude Code resume is observed by joining the receiver marker its Entwurf hook re-arms to the
|
|
638
|
+
// process this launch started: the hook names its parent, the Claude Code process, as the marker's
|
|
639
|
+
// owner. `agent start` types the command into the pane's interactive shell, so that process is the
|
|
640
|
+
// shell's child and no herdr coordinate names it. A layout argv pane spawns the vector's program as
|
|
641
|
+
// the pane's direct child (`[측정 S4-CC-A H1, 2026-11-02, herdr 0.9.5 at the pinned aarch64 digest]`:
|
|
642
|
+
// a program's self-reported pid, written before one exec, equalled `pane.process_info` `shell_pid`
|
|
643
|
+
// and its foreground pid, at the same kernel start time, with the herdr server as its parent). So
|
|
644
|
+
// this branch is the pi-durable layout launch with the record's argv and no adopt env, followed by ONE
|
|
645
|
+
// read of that report for the pane it created. The read mutates nothing; whatever it says, the tab
|
|
646
|
+
// stays as it is — this branch closes nothing, as the pi-durable one closes nothing.
|
|
647
|
+
//
|
|
648
|
+
// The runtime is the bare name `claude`, resolved by herdr exactly as its `agent start` resolves the
|
|
649
|
+
// fresh Claude Code launch: this module imports no tmux leaf, so it carries no PATH resolver, and the
|
|
650
|
+
// receipt says herdr resolved it. Which binary ran is not what the observation proves; the join does.
|
|
651
|
+
|
|
652
|
+
/** What herdr is asked to run first in a Claude Code resume's layout vector. */
|
|
653
|
+
export const HERDR_CLAUDE_CODE_RESUME_RUNTIME = "claude";
|
|
654
|
+
|
|
655
|
+
/** Why a Claude Code herdr resume was refused BEFORE the layout write — the pi-durable layout words
|
|
656
|
+
* that do not name its package. */
|
|
657
|
+
export type HerdrClaudeCodeResumeRejectReason = Extract<
|
|
658
|
+
HerdrPiDurableResumeRejectReason,
|
|
659
|
+
| "herdr-context-missing"
|
|
660
|
+
| "herdr-caller-pane-missing"
|
|
661
|
+
| "cwd-not-absolute"
|
|
662
|
+
| "cwd-missing"
|
|
663
|
+
| "cwd-not-directory"
|
|
664
|
+
| "herdr-socket-path-missing"
|
|
665
|
+
| "herdr-argv-control-character"
|
|
666
|
+
| "herdr-caller-pane-get-failed"
|
|
667
|
+
| "herdr-caller-pane-unparsable"
|
|
668
|
+
| "herdr-caller-pane-drift"
|
|
669
|
+
| "herdr-caller-workspace-missing"
|
|
670
|
+
>;
|
|
671
|
+
|
|
672
|
+
/** The pane's direct child as herdr reported it right after the layout created the pane, or why no
|
|
673
|
+
* pid came back. A pid is a JOIN for the v2 observation, never an address. */
|
|
674
|
+
export type HerdrPaneChild = { readonly pid: string } | { readonly unread: string };
|
|
675
|
+
|
|
676
|
+
/** What the layout reply and the one process read proved for THIS launch. No agent name, no terminal
|
|
677
|
+
* id, no garden or native session id. */
|
|
678
|
+
export interface HerdrClaudeCodeResumeReceipt {
|
|
679
|
+
/** What herdr was asked to run first — a name herdr resolved, not a path this module checked. */
|
|
680
|
+
readonly runtime: string;
|
|
681
|
+
/** The REQUESTED start directory, the record's — not an observation of where the pane landed. */
|
|
682
|
+
readonly cwd: string;
|
|
683
|
+
readonly herdrWorkspaceId: string;
|
|
684
|
+
readonly herdrTabId: string;
|
|
685
|
+
readonly herdrPaneId: string;
|
|
686
|
+
readonly paneChild: HerdrPaneChild;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export type HerdrClaudeCodeResumeResult =
|
|
690
|
+
| { readonly ok: true; readonly receipt: HerdrClaudeCodeResumeReceipt }
|
|
691
|
+
| {
|
|
692
|
+
readonly ok: false;
|
|
693
|
+
readonly reason: HerdrClaudeCodeResumeRejectReason;
|
|
694
|
+
readonly reported?: HerdrReportedDiagnostics;
|
|
695
|
+
}
|
|
696
|
+
| HerdrLayoutFailure;
|
|
697
|
+
|
|
698
|
+
/** The fixed process read. Product function, not an RPC surface: method and shape are literal. */
|
|
699
|
+
export function buildHerdrPaneProcessInfoPacket(id: string, paneId: string): string {
|
|
700
|
+
return `${JSON.stringify({ id, method: "pane.process_info", params: { pane_id: paneId } })}\n`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
704
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The process read, reduced to the pane's direct-child pid. A success must name THIS pane and carry
|
|
709
|
+
* `shell_pid` (herdr's name for the pane runtime's direct child, `integer | null` in its bundled
|
|
710
|
+
* schema) as an integer above 1; anything else is `unread` with its cause — a pane whose process
|
|
711
|
+
* already exited reports null, and that is not a pid to join.
|
|
712
|
+
*/
|
|
713
|
+
export function classifyHerdrPaneProcessInfo(outcome: HerdrSocketOutcome, id: string, paneId: string): HerdrPaneChild {
|
|
714
|
+
if (outcome.kind !== "reply") {
|
|
715
|
+
return { unread: boundHerdrDiagnostic("code" in outcome ? `${outcome.kind}: ${outcome.code}` : outcome.kind) };
|
|
716
|
+
}
|
|
717
|
+
const envelope = readHerdrEnvelope(outcome.line, id);
|
|
718
|
+
if (envelope === null) return { unread: "herdr's process reply could not be read" };
|
|
719
|
+
if (envelope.arm === "error")
|
|
720
|
+
return { unread: boundHerdrDiagnostic(`herdr error ${envelope.code}: ${envelope.message}`) };
|
|
721
|
+
const info = envelope.result.process_info;
|
|
722
|
+
if (envelope.result.type !== "pane_process_info" || !isRecord(info) || info.pane_id !== paneId) {
|
|
723
|
+
return { unread: "herdr's process reply does not describe the pane this call created" };
|
|
724
|
+
}
|
|
725
|
+
const pid = info.shell_pid;
|
|
726
|
+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 1) {
|
|
727
|
+
return { unread: `herdr reported no live direct child for the pane (shell_pid ${JSON.stringify(pid ?? null)})` };
|
|
728
|
+
}
|
|
729
|
+
return { pid: String(pid) };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Open the dormant Claude Code session in a new herdr tab: `claude` plus the record-derived argv
|
|
734
|
+
* (`--resume <session id>`, built on the v2 side and passed through untouched), the record's cwd, and
|
|
735
|
+
* the identity scrub as the env map — then read which process the new pane runs.
|
|
736
|
+
*/
|
|
737
|
+
export async function herdrClaudeCodeResumeCall(
|
|
738
|
+
params: { readonly cwd: string; readonly runtimeArgs: readonly string[] },
|
|
739
|
+
env: NodeJS.ProcessEnv,
|
|
740
|
+
deps: Pick<HerdrLayoutDeps, "socket"> = HERDR_LAYOUT_PRODUCTION_DEPS,
|
|
741
|
+
requestBase: string = mintHerdrResumeAgentName(),
|
|
742
|
+
): Promise<HerdrClaudeCodeResumeResult> {
|
|
743
|
+
const herdr = resolveHerdrContext(env);
|
|
744
|
+
if (!herdr.ok) {
|
|
745
|
+
if (herdr.reason === "herdr-context-missing" || herdr.reason === "herdr-caller-pane-missing") {
|
|
746
|
+
return { ok: false, reason: herdr.reason };
|
|
747
|
+
}
|
|
748
|
+
throw new Error(`herdr-resume-call: the herdr context refused as ${herdr.reason}, which this rail does not name`);
|
|
749
|
+
}
|
|
750
|
+
const badCwd = classifyHerdrCwd(params.cwd);
|
|
751
|
+
if (badCwd !== null) return { ok: false, reason: badCwd };
|
|
752
|
+
const socketPath = resolveHerdrSocketPath(env);
|
|
753
|
+
if (socketPath === null) return { ok: false, reason: "herdr-socket-path-missing" };
|
|
754
|
+
|
|
755
|
+
const command = [HERDR_CLAUDE_CODE_RESUME_RUNTIME, ...params.runtimeArgs];
|
|
756
|
+
const launchEnv: Record<string, string> = { PI_SESSION_ID: "", PI_AGENT_ID: "" };
|
|
757
|
+
if (command.some(containsControlChar)) return { ok: false, reason: "herdr-argv-control-character" };
|
|
758
|
+
|
|
759
|
+
const call = deps.socket(socketPath);
|
|
760
|
+
const readId = `${requestBase}:pane.get`;
|
|
761
|
+
const read = classifyHerdrLayoutPaneRead(
|
|
762
|
+
await call(buildHerdrPaneGetPacket(readId, herdr.context.callerPaneId)),
|
|
763
|
+
readId,
|
|
764
|
+
herdr.context.callerPaneId,
|
|
765
|
+
);
|
|
766
|
+
if (!read.ok) return { ok: false, reason: read.reason, reported: read.reported };
|
|
767
|
+
|
|
768
|
+
// ── everything above this line leaves nothing behind; the ONE layout mutation write follows ──
|
|
769
|
+
const writeId = `${requestBase}:layout.apply`;
|
|
770
|
+
const applied = classifyHerdrLayoutApplyReply(
|
|
771
|
+
await call(
|
|
772
|
+
buildHerdrLayoutApplyPacket(writeId, { workspaceId: read.workspaceId, cwd: params.cwd, command, env: launchEnv }),
|
|
773
|
+
),
|
|
774
|
+
{ id: writeId, workspaceId: read.workspaceId, cwd: params.cwd, command },
|
|
775
|
+
);
|
|
776
|
+
if (!applied.ok) return applied;
|
|
777
|
+
// The tab exists from here on. ONE read of what the pane this call created is running; whatever it
|
|
778
|
+
// says is reported, and nothing is closed or retried.
|
|
779
|
+
const { workspaceId, tabId, paneId } = applied;
|
|
780
|
+
const processId = `${requestBase}:pane.process_info`;
|
|
781
|
+
const paneChild = classifyHerdrPaneProcessInfo(
|
|
782
|
+
await call(buildHerdrPaneProcessInfoPacket(processId, paneId)),
|
|
783
|
+
processId,
|
|
784
|
+
paneId,
|
|
785
|
+
);
|
|
786
|
+
const receipt: HerdrClaudeCodeResumeReceipt = {
|
|
787
|
+
runtime: HERDR_CLAUDE_CODE_RESUME_RUNTIME,
|
|
788
|
+
cwd: params.cwd,
|
|
789
|
+
herdrWorkspaceId: workspaceId,
|
|
790
|
+
herdrTabId: tabId,
|
|
791
|
+
herdrPaneId: paneId,
|
|
792
|
+
paneChild,
|
|
793
|
+
};
|
|
794
|
+
return { ok: true, receipt };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** The one shared word whose fresh-rail hint names pi-durable: for this launch it names Claude Code. */
|
|
798
|
+
export const HERDR_CLAUDE_CODE_SOCKET_PATH_HINT =
|
|
799
|
+
"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";
|
|
800
|
+
|
|
801
|
+
/** The Claude Code layout result as the v2 seam reads it: a `herdr-layout-claude` handle, or — every
|
|
802
|
+
* refusal and layout failure being one the pi-durable layout branch also names — that branch's own
|
|
803
|
+
* mapping, so the two layout launches cannot classify one herdr fact two ways. The one word whose hint
|
|
804
|
+
* names pi-durable is reworded for Claude Code; every other hint names no backend. */
|
|
805
|
+
export function herdrClaudeCodeResumeLaunchOutcome(result: HerdrClaudeCodeResumeResult): HerdrResumeLaunchOutcome {
|
|
806
|
+
if (result.ok) return { ok: true, handle: { rail: "herdr-layout-claude", ...result.receipt } };
|
|
807
|
+
const shared = herdrPiDurableResumeLaunchOutcome(result);
|
|
808
|
+
if (shared.ok || result.reason !== "herdr-socket-path-missing") return shared;
|
|
809
|
+
return { ...shared, hint: HERDR_CLAUDE_CODE_SOCKET_PATH_HINT };
|
|
810
|
+
}
|