@junghanacs/entwurf 0.40.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -2
- package/BASELINE.md +55 -2
- package/CHANGELOG.md +321 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +18 -2
- package/FAQ.md +8 -4
- package/README.md +26 -8
- package/VERIFY.md +13 -10
- package/docs/acp-backend-rail.md +5 -1
- package/docs/external-mcp-host.md +8 -6
- package/docs/fresh-cut-policy.md +9 -1
- package/docs/setup-clean-host.md +24 -8
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +81 -19
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/src/index.ts +93 -19
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/mcp/entwurf-bridge/tsconfig.build.json +10 -1
- package/package.json +4 -4
- package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
- package/pi-extensions/entwurf-control.ts +55 -20
- package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/pi-extensions/lib/cc-retire-request.ts +553 -0
- package/pi-extensions/lib/control-retire.ts +195 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
- package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
- package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
- package/pi-extensions/lib/fresh-call-composition.ts +15 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/meta-session.ts +140 -16
- package/pi-extensions/lib/mux-fresh-call.ts +7 -27
- package/pi-extensions/lib/mux-launch.ts +29 -0
- package/pi-extensions/lib/mux-resume-call.ts +33 -12
- package/pi-extensions/lib/pi-retire-request.ts +111 -0
- package/pi-extensions/lib/resume-launch-identity.ts +104 -18
- package/pi-extensions/lib/retirement-event.ts +253 -0
- package/pi-extensions/meta-bridge-hook.ts +142 -13
- package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
- package/run.sh +78 -7
- package/scripts/agy-bridge-config.py +4 -1
- package/scripts/build-bridge.sh +14 -0
- package/scripts/check-capability-bundle-reach.ts +135 -2
- package/scripts/check-entwurf-bridge-boot.ts +2 -1
- package/scripts/check-entwurf-fact-provider.ts +1 -0
- package/scripts/check-entwurf-v2-production.ts +2 -0
- package/scripts/check-entwurf-v2-surface.ts +7 -0
- package/scripts/check-gate-qualification.ts +14 -8
- package/scripts/check-herdr-activation.ts +2 -2
- package/scripts/check-herdr-placement.ts +1 -0
- package/scripts/check-hook-launch-topology.ts +6 -15
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-meta-doctor-oracle.sh +27 -1
- package/scripts/check-meta-facts.ts +8 -3
- package/scripts/check-meta-hook-session-switch.ts +230 -13
- package/scripts/check-meta-identity-consumers.ts +1 -0
- package/scripts/check-meta-listing.ts +1 -0
- package/scripts/check-meta-manifest-schema.py +1 -1
- package/scripts/check-meta-session.ts +31 -0
- package/scripts/check-meta-v3-record.ts +55 -1
- package/scripts/check-mux-resume-call.ts +35 -9
- package/scripts/check-peer-facts.ts +1 -0
- package/scripts/check-pi-durable-contact.ts +3 -1
- package/scripts/check-pi-durable-send.ts +4 -2
- package/scripts/check-pi-mcp-bridge.ts +1 -0
- package/scripts/check-resume-launch-identity.ts +62 -23
- package/scripts/lib/meta-hook-bundle.ts +72 -0
- package/scripts/meta-bridge-claude-floor.sh +13 -9
- package/scripts/meta-bridge-doctor.sh +17 -0
- package/scripts/meta-bridge-install.sh +6 -0
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/bridge-boot-resume.json +5 -2
- package/scripts/mutants/cc-retire-pidfd.json +129 -0
- package/scripts/mutants/cc-retire-request.json +171 -0
- package/scripts/mutants/control-retire.json +105 -0
- package/scripts/mutants/copilot-receive.json +2 -6
- package/scripts/mutants/entwurf-v2-retire.json +476 -0
- package/scripts/mutants/fresh-call-dispatch.json +37 -0
- package/scripts/mutants/meta-hook-session-switch.json +88 -0
- package/scripts/mutants/meta-identity.json +57 -0
- package/scripts/mutants/mux-fresh-call.json +1 -1
- package/scripts/mutants/mux-resume-call.json +40 -4
- package/scripts/mutants/pi-durable.json +79 -0
- package/scripts/mutants/pi-retire-request.json +72 -0
- package/scripts/mutants/resume-launch-identity.json +35 -8
- package/scripts/mutants/retirement-event.json +195 -0
- package/scripts/mutants/v2-surface.json +16 -0
- package/scripts/mutants/v2-visible-resume.json +283 -13
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +5 -4
- package/scripts/smoke-meta-install-state.sh +4 -4
- package/scripts/smoke-mux-lifecycle-live.ts +10 -10
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
- package/scripts/smoke-pi-provider-state.sh +3 -3
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* fresh-call-dispatch — the ONE place that decides which rail opens a sibling, and the only module
|
|
3
3
|
* allowed to know that there are two.
|
|
4
4
|
*
|
|
5
|
-
* WHY A COMPOSITION ROOT AND NOT A BRANCH IN EACH SURFACE. Two surfaces
|
|
5
|
+
* WHY A COMPOSITION ROOT AND NOT A BRANCH IN EACH SURFACE. Two surfaces once registered
|
|
6
6
|
* `entwurf_fresh_call`: pi's own extension and the MCP bridge. Before this file each of them
|
|
7
7
|
* carried the Codex preflight ordering and the render call itself, and adding a rail choice to
|
|
8
8
|
* both would have made four places where the wrong one could be picked. A defect planted in one
|
|
9
|
-
* copy survives on the other, which is precisely the shape a mutant proves nothing about.
|
|
10
|
-
*
|
|
9
|
+
* copy survives on the other, which is precisely the shape a mutant proves nothing about. Since
|
|
10
|
+
* #125 the bridge is the only registration, and it still supplies only identity and an env; this
|
|
11
|
+
* module supplies everything else.
|
|
11
12
|
*
|
|
12
13
|
* THE RAIL IS A CAPABILITY FACT, NOT A PARAMETER. `HERDR_ENV=1` — exactly that value, herdr's own
|
|
13
14
|
* variable — means this process is inside herdr and the herdr rail is the honest placement. Every
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
import { spawn as spawnChildProcess } from "node:child_process";
|
|
28
29
|
import { ACP_MODEL_NOT_CURATED, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
|
|
29
30
|
import { codexCallerFreshPreflight, codexFreshPreflight } from "./codex-fresh-preflight.ts";
|
|
30
|
-
import { isSafeFreshCallModel, mintNonce } from "./fresh-call-composition.ts";
|
|
31
|
+
import { type FreshCallProviderRejectReason, isSafeFreshCallModel, mintNonce } from "./fresh-call-composition.ts";
|
|
31
32
|
import {
|
|
32
33
|
createHerdrRunner,
|
|
33
34
|
type HerdrFreshCallResult,
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
type SpawnFn,
|
|
39
40
|
} from "./herdr-fresh-call.ts";
|
|
40
41
|
import { type FreshCallResult, freshCall, renderFreshCall } from "./mux-fresh-call.ts";
|
|
42
|
+
import { splitPiDurableModel } from "./pi-durable-fresh-preflight.ts";
|
|
41
43
|
|
|
42
44
|
export type FreshCallRail = "herdr" | "tmux";
|
|
43
45
|
|
|
@@ -68,6 +70,38 @@ export type DispatchedFreshCall =
|
|
|
68
70
|
| { readonly rail: "tmux"; readonly result: FreshCallResult }
|
|
69
71
|
| { readonly rail: "herdr"; readonly result: HerdrFreshCallResult };
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* #76: the provider a Pi-dialect model string NAMES. pi passes the string to `--model` and Pi
|
|
75
|
+
* resolves it; pi-durable splits it at the first `/` into `--provider`. So for those two backends
|
|
76
|
+
* the segment before the first `/` is a provider choice, and for every other backend it is that
|
|
77
|
+
* vendor's own model name, which this does not judge (a claude-code `opus` stays an alias).
|
|
78
|
+
*
|
|
79
|
+
* model-openrouter-forbidden pi or pi-durable names the OpenRouter provider. Compared in
|
|
80
|
+
* any letter case: whether Pi folds case is not measured here,
|
|
81
|
+
* so the spelling difference fails closed instead of passing.
|
|
82
|
+
* pi-model-not-provider-qualified pi names no provider at all. The alias leaves the provider
|
|
83
|
+
* choice to Pi's catalog instead of the caller naming it — a
|
|
84
|
+
* bare `terra` once opened on an OpenRouter row. pi-durable
|
|
85
|
+
* already refuses the same shape in its own preflight, by the
|
|
86
|
+
* same predicate.
|
|
87
|
+
*
|
|
88
|
+
* WHAT THIS IS NOT. A call-time input contract only: it checks the string a caller wrote, never a
|
|
89
|
+
* model version, never a list of known ids, never a resolved row or the endpoint a provider name
|
|
90
|
+
* reaches. A later model name passes THIS provider check once it is written `<provider>/<id>` —
|
|
91
|
+
* which says nothing about whether it launches or what #128 decides after it. It does not touch
|
|
92
|
+
* resume, which reopens from the record, and it proves nothing about billing beyond the name.
|
|
93
|
+
* Read trimmed, and only once the rails' grammar admits the model, exactly like #128 below.
|
|
94
|
+
*/
|
|
95
|
+
export function freshCallProviderRefusal(backend: string, model: string): FreshCallProviderRejectReason | null {
|
|
96
|
+
if (backend !== "pi" && backend !== "pi-durable") return null;
|
|
97
|
+
const trimmed = model.trim();
|
|
98
|
+
if (!isSafeFreshCallModel(trimmed)) return null;
|
|
99
|
+
const slash = trimmed.indexOf("/");
|
|
100
|
+
if (slash >= 0 && trimmed.slice(0, slash).toLowerCase() === "openrouter") return "model-openrouter-forbidden";
|
|
101
|
+
if (backend === "pi" && splitPiDurableModel(trimmed) === null) return "pi-model-not-provider-qualified";
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
71
105
|
/**
|
|
72
106
|
* #128: a pi sibling asked for under the `entwurf` provider must name an EXACT curated ACP id. Pi
|
|
73
107
|
* resolves `--model` by substring among that provider's rows, so a retired `entwurf/claude-sonnet-5`
|
|
@@ -106,6 +140,13 @@ export async function dispatchFreshCall(
|
|
|
106
140
|
spawn?: SpawnFn,
|
|
107
141
|
nonce: string = mintNonce(),
|
|
108
142
|
): Promise<DispatchedFreshCall> {
|
|
143
|
+
// Before either rail is asked anything: both would open a pane on a provider nobody chose.
|
|
144
|
+
const providerRefusal = freshCallProviderRefusal(request.backend, request.model);
|
|
145
|
+
if (providerRefusal !== null) {
|
|
146
|
+
return selectFreshCallRail(env) === "herdr"
|
|
147
|
+
? { rail: "herdr", result: { ok: false, reason: providerRefusal } }
|
|
148
|
+
: { rail: "tmux", result: { ok: false, reason: providerRefusal } };
|
|
149
|
+
}
|
|
109
150
|
// Before either rail is asked anything: both would open a pane on the wrong row.
|
|
110
151
|
if (isUncuratedEntwurfPiModel(request.backend, request.model)) {
|
|
111
152
|
return selectFreshCallRail(env) === "herdr"
|
|
@@ -50,8 +50,10 @@ import {
|
|
|
50
50
|
buildOmpBootstrapPayload,
|
|
51
51
|
composeBackendArgs,
|
|
52
52
|
composeFreshCallFraming,
|
|
53
|
+
FRESH_CALL_PROVIDER_HINT,
|
|
53
54
|
type FreshCallComposition,
|
|
54
55
|
type FreshCallInputRejectReason,
|
|
56
|
+
type FreshCallProviderRejectReason,
|
|
55
57
|
normalizeFreshCallInputs,
|
|
56
58
|
TASK_MAX_CHARS,
|
|
57
59
|
} from "./fresh-call-composition.ts";
|
|
@@ -119,6 +121,7 @@ export type HerdrFreshCallRejectReason =
|
|
|
119
121
|
// The caller-facing input contract, shared with the tmux rail so a mistyped model or an
|
|
120
122
|
// oversized task is answered with the SAME word on either placement owner.
|
|
121
123
|
| FreshCallInputRejectReason
|
|
124
|
+
| FreshCallProviderRejectReason
|
|
122
125
|
| "acp-model-not-curated"
|
|
123
126
|
| "herdr-context-missing"
|
|
124
127
|
| "herdr-caller-pane-missing"
|
|
@@ -1099,6 +1102,8 @@ async function reclaim(pane: HerdrPaneFacts, run: HerdrRun): Promise<HerdrRecove
|
|
|
1099
1102
|
* they will guess about. The five shared input reasons keep the wording the public verb has always
|
|
1100
1103
|
* used; the rest are this rail's own. */
|
|
1101
1104
|
const HERDR_REJECT_HINT: Record<HerdrFreshCallRejectReason | HerdrLaunchFailureReason, string> = {
|
|
1105
|
+
// #76: decided at the dispatch root, worded once in the shared composition leaf.
|
|
1106
|
+
...FRESH_CALL_PROVIDER_HINT,
|
|
1102
1107
|
"caller-identity-unavailable":
|
|
1103
1108
|
"this surface has no record-backed identity, so a sibling would have nowhere to call home",
|
|
1104
1109
|
"model-empty": "pass the model you want the sibling opened with — it is never inherited from this process",
|
|
@@ -354,6 +354,17 @@ export interface MetaIdentity {
|
|
|
354
354
|
transcriptPath: string | null;
|
|
355
355
|
createdAt: string;
|
|
356
356
|
recordUpdatedAt: string;
|
|
357
|
+
/**
|
|
358
|
+
* The Claude Code arm binding (#147 L2 B1): a clock-independent 128-bit random value
|
|
359
|
+
* (`randomBytes(16)`, {@link mintAttachIncarnation}) the CC hook mints on every
|
|
360
|
+
* arm-capable event and writes in the SAME atomic upsert as that attach, then copies into
|
|
361
|
+
* the receiver marker it writes under the record-arm lock. A dead marker carrying the
|
|
362
|
+
* value the record still carries is evidence that no later arm-capable attach happened;
|
|
363
|
+
* one that does not carry it is not. `null` means no binding was ever observed — every
|
|
364
|
+
* other backend, a record no arm has touched since this field existed, a create — and
|
|
365
|
+
* is never filled by inference. Every writer that does not mint PRESERVES it.
|
|
366
|
+
*/
|
|
367
|
+
attachIncarnation: string | null;
|
|
357
368
|
}
|
|
358
369
|
|
|
359
370
|
export function requireCitizenBackend(value: unknown): MetaCitizenBackend {
|
|
@@ -476,8 +487,28 @@ const META_IDENTITY_KEYS: readonly string[] = [
|
|
|
476
487
|
"transcriptPath",
|
|
477
488
|
"createdAt",
|
|
478
489
|
"recordUpdatedAt",
|
|
490
|
+
"attachIncarnation",
|
|
479
491
|
];
|
|
480
492
|
|
|
493
|
+
/** `attachIncarnation` grammar: 32 lowercase hex characters (`randomBytes(16)`). */
|
|
494
|
+
const ATTACH_INCARNATION_RE = /^[0-9a-f]{32}$/;
|
|
495
|
+
|
|
496
|
+
/** Validate an `attachIncarnation`: null, or the 32-hex grammar. Throws on anything else. */
|
|
497
|
+
export function requireAttachIncarnation(value: unknown): string | null {
|
|
498
|
+
if (value === null) return null;
|
|
499
|
+
if (typeof value !== "string" || !ATTACH_INCARNATION_RE.test(value)) {
|
|
500
|
+
throw new MetaRecordError(
|
|
501
|
+
`meta-record field "attachIncarnation" must be null or 32 lowercase hex characters (got ${describe(value)}).`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Mint one arm binding value. Clock-independent: 128 random bits, never derived from time. */
|
|
508
|
+
export function mintAttachIncarnation(): string {
|
|
509
|
+
return crypto.randomBytes(16).toString("hex");
|
|
510
|
+
}
|
|
511
|
+
|
|
481
512
|
/** Parse + fully validate untrusted JSON into a v3 MetaIdentity. Throws on any drift. */
|
|
482
513
|
export function parseMetaRecordV3(json: string): MetaIdentity {
|
|
483
514
|
let raw: unknown;
|
|
@@ -514,6 +545,10 @@ export function parseMetaRecordV3(json: string): MetaIdentity {
|
|
|
514
545
|
transcriptPath: requireNullableString(obj.transcriptPath, "transcriptPath"),
|
|
515
546
|
createdAt: requireNonEmptyString(obj.createdAt, "createdAt"),
|
|
516
547
|
recordUpdatedAt: requireNonEmptyString(obj.recordUpdatedAt, "recordUpdatedAt"),
|
|
548
|
+
// The one key a V3 record may omit: a record nothing has touched since the field
|
|
549
|
+
// existed reads its absence as null ("no binding observed"). One schema, one reader —
|
|
550
|
+
// not a legacy alias: every serialize emits the key.
|
|
551
|
+
attachIncarnation: requireAttachIncarnation(obj.attachIncarnation === undefined ? null : obj.attachIncarnation),
|
|
517
552
|
};
|
|
518
553
|
}
|
|
519
554
|
|
|
@@ -533,6 +568,7 @@ export function normalizeMetaIdentity(record: MetaIdentity): MetaIdentity {
|
|
|
533
568
|
transcriptPath: record.transcriptPath,
|
|
534
569
|
createdAt: record.createdAt,
|
|
535
570
|
recordUpdatedAt: record.recordUpdatedAt,
|
|
571
|
+
attachIncarnation: record.attachIncarnation,
|
|
536
572
|
};
|
|
537
573
|
}
|
|
538
574
|
|
|
@@ -559,6 +595,7 @@ export function serializeMetaIdentity(identity: MetaIdentity): string {
|
|
|
559
595
|
transcriptPath: identity.transcriptPath,
|
|
560
596
|
createdAt: identity.createdAt,
|
|
561
597
|
recordUpdatedAt: identity.recordUpdatedAt,
|
|
598
|
+
attachIncarnation: identity.attachIncarnation,
|
|
562
599
|
};
|
|
563
600
|
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
564
601
|
}
|
|
@@ -576,6 +613,13 @@ export interface MetaIdentityMintInput {
|
|
|
576
613
|
cwd: string;
|
|
577
614
|
model?: string | null;
|
|
578
615
|
transcriptPath?: string | null;
|
|
616
|
+
/**
|
|
617
|
+
* The arm binding to commit with this write. ONLY the Claude Code hook passes it, and
|
|
618
|
+
* only on an arm-capable event under the record-arm lock; `undefined` (every other
|
|
619
|
+
* writer, every UserPromptSubmit) keeps the existing value, and a create without it is
|
|
620
|
+
* `null`.
|
|
621
|
+
*/
|
|
622
|
+
attachIncarnation?: string;
|
|
579
623
|
}
|
|
580
624
|
|
|
581
625
|
/**
|
|
@@ -598,6 +642,7 @@ export function mintMetaIdentity(input: MetaIdentityMintInput, now: Date = new D
|
|
|
598
642
|
transcriptPath: requireNullableString(input.transcriptPath ?? null, "transcriptPath"),
|
|
599
643
|
createdAt: ts,
|
|
600
644
|
recordUpdatedAt: ts,
|
|
645
|
+
attachIncarnation: input.attachIncarnation === undefined ? null : requireAttachIncarnation(input.attachIncarnation),
|
|
601
646
|
};
|
|
602
647
|
}
|
|
603
648
|
|
|
@@ -1314,9 +1359,14 @@ export function decideUpsert(
|
|
|
1314
1359
|
input.transcriptPath === undefined
|
|
1315
1360
|
? existing.transcriptPath
|
|
1316
1361
|
: requireNullableString(input.transcriptPath, "transcriptPath");
|
|
1362
|
+
// The arm binding is not a merge axis: only a mint replaces it, and nothing clears it.
|
|
1363
|
+
const attachIncarnation =
|
|
1364
|
+
input.attachIncarnation === undefined
|
|
1365
|
+
? existing.attachIncarnation
|
|
1366
|
+
: requireAttachIncarnation(input.attachIncarnation);
|
|
1317
1367
|
return {
|
|
1318
1368
|
action: "attach",
|
|
1319
|
-
record: { ...existing, cwd, model, transcriptPath, recordUpdatedAt: isoNow(now) },
|
|
1369
|
+
record: { ...existing, cwd, model, transcriptPath, recordUpdatedAt: isoNow(now), attachIncarnation },
|
|
1320
1370
|
};
|
|
1321
1371
|
}
|
|
1322
1372
|
|
|
@@ -1416,6 +1466,17 @@ export function defaultMetaReceiversDir(): string {
|
|
|
1416
1466
|
return path.join(piAgentDir(), "meta-receivers");
|
|
1417
1467
|
}
|
|
1418
1468
|
|
|
1469
|
+
/**
|
|
1470
|
+
* The record-arm lock directory (#147 L2 B1): a sibling of the receivers dir, so a
|
|
1471
|
+
* sandboxed receivers root sandboxes it too and no new environment carrier exists. It is
|
|
1472
|
+
* deliberately NOT the v2 dispatch lock dir — visible resume holds that per-gid lock across
|
|
1473
|
+
* its whole launch + observation window, and the relaunched session's own arm must not wait
|
|
1474
|
+
* on it. Only the Claude Code hook takes this lock; nothing else reads or writes here.
|
|
1475
|
+
*/
|
|
1476
|
+
export function defaultMetaRecordArmLocksDir(receiversDir: string = defaultMetaReceiversDir()): string {
|
|
1477
|
+
return path.join(path.dirname(receiversDir), "meta-record-arm-locks");
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1419
1480
|
// ---------------------------------------------------------------------------
|
|
1420
1481
|
// OMP meta-root policy (#87 B1) — ADDITIVE, and only for backend `omp`.
|
|
1421
1482
|
//
|
|
@@ -1990,6 +2051,12 @@ export interface MetaReceiverMarker {
|
|
|
1990
2051
|
/** Which arm-capable event wrote this presence (never user-prompt-submit). */
|
|
1991
2052
|
armProvenance: MetaReceiverArmProvenance;
|
|
1992
2053
|
updatedAt: string;
|
|
2054
|
+
/**
|
|
2055
|
+
* A copy of the record's `attachIncarnation` this arm committed (#147 L2 B1), written only by
|
|
2056
|
+
* the Claude Code hook under the record-arm lock. `null` when the writer supplied none (every
|
|
2057
|
+
* other owner kind, a marker older than the field). Readers that predate the field ignore it.
|
|
2058
|
+
*/
|
|
2059
|
+
attachIncarnation: string | null;
|
|
1993
2060
|
}
|
|
1994
2061
|
|
|
1995
2062
|
/** `<receiversDir>/<gardenId>.json` — keyed by garden id (the universal address). */
|
|
@@ -2003,6 +2070,8 @@ export interface WriteMetaReceiverMarkerOptions {
|
|
|
2003
2070
|
nativeSessionId: string;
|
|
2004
2071
|
ownerPid: number;
|
|
2005
2072
|
armProvenance: MetaReceiverArmProvenance;
|
|
2073
|
+
/** The record binding this arm committed; only the Claude Code hook passes it. */
|
|
2074
|
+
attachIncarnation?: string;
|
|
2006
2075
|
/** Defaults to "claude-code-cli" — the watchPaths subscriber. */
|
|
2007
2076
|
ownerKind?: string;
|
|
2008
2077
|
receiversDir?: string;
|
|
@@ -2025,6 +2094,7 @@ export function writeMetaReceiverMarker(opts: WriteMetaReceiverMarkerOptions): s
|
|
|
2025
2094
|
ownerKind: requireNonEmptyString(opts.ownerKind ?? "claude-code-cli", "ownerKind"),
|
|
2026
2095
|
armProvenance: requireArmProvenance(opts.armProvenance),
|
|
2027
2096
|
updatedAt: isoNow(opts.now ?? new Date()),
|
|
2097
|
+
attachIncarnation: opts.attachIncarnation === undefined ? null : requireAttachIncarnation(opts.attachIncarnation),
|
|
2028
2098
|
};
|
|
2029
2099
|
const tmp = `${file}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
2030
2100
|
fs.writeFileSync(tmp, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
|
|
@@ -2042,6 +2112,31 @@ export interface ReadMetaReceiverMarkerOptions {
|
|
|
2042
2112
|
verifyOwner?: boolean;
|
|
2043
2113
|
}
|
|
2044
2114
|
|
|
2115
|
+
/**
|
|
2116
|
+
* Validate receiver-marker bytes — the one validator, shared by the path reader below and by
|
|
2117
|
+
* the no-follow fd reader visible resume uses (#147 L2 D3). Throws on a malformed marker;
|
|
2118
|
+
* returns null for an IMPLAUSIBLE owner pid. Same rule as the sender marker, and for the same
|
|
2119
|
+
* reason one layer over: an impossible owner is not a live one, so a refuted marker must never
|
|
2120
|
+
* read back as an ACTIVE RECEIVER and pull the mailbox rail into delivering to a void. An
|
|
2121
|
+
* absent `attachIncarnation` reads as null; a present malformed one throws.
|
|
2122
|
+
*/
|
|
2123
|
+
export function parseMetaReceiverMarker(json: string): MetaReceiverMarker | null {
|
|
2124
|
+
const raw = JSON.parse(json);
|
|
2125
|
+
const marker: MetaReceiverMarker = {
|
|
2126
|
+
gardenId: requireGardenId(raw.gardenId),
|
|
2127
|
+
backend: requireBackend(raw.backend),
|
|
2128
|
+
nativeSessionId: requireNonEmptyString(raw.nativeSessionId, "nativeSessionId"),
|
|
2129
|
+
ownerPid: typeof raw.ownerPid === "number" ? raw.ownerPid : Number.NaN,
|
|
2130
|
+
ownerStartKey: requireNonEmptyString(raw.ownerStartKey, "ownerStartKey"),
|
|
2131
|
+
ownerKind: requireNonEmptyString(raw.ownerKind, "ownerKind"),
|
|
2132
|
+
armProvenance: requireArmProvenance(raw.armProvenance),
|
|
2133
|
+
updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
|
|
2134
|
+
attachIncarnation: requireAttachIncarnation(raw.attachIncarnation === undefined ? null : raw.attachIncarnation),
|
|
2135
|
+
};
|
|
2136
|
+
if (!isPlausibleOwnerPid(marker.ownerPid)) return null;
|
|
2137
|
+
return marker;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2045
2140
|
/**
|
|
2046
2141
|
* Read the receiver presence marker for a garden id. Returns null when absent,
|
|
2047
2142
|
* corrupt, or (under verifyOwner) the owner pid is no longer the same live process —
|
|
@@ -2057,21 +2152,8 @@ export function readMetaReceiverMarker(opts: ReadMetaReceiverMarkerOptions): Met
|
|
|
2057
2152
|
}
|
|
2058
2153
|
if (!file || !fs.existsSync(file)) return null;
|
|
2059
2154
|
try {
|
|
2060
|
-
const
|
|
2061
|
-
|
|
2062
|
-
gardenId: requireGardenId(raw.gardenId),
|
|
2063
|
-
backend: requireBackend(raw.backend),
|
|
2064
|
-
nativeSessionId: requireNonEmptyString(raw.nativeSessionId, "nativeSessionId"),
|
|
2065
|
-
ownerPid: typeof raw.ownerPid === "number" ? raw.ownerPid : Number.NaN,
|
|
2066
|
-
ownerStartKey: requireNonEmptyString(raw.ownerStartKey, "ownerStartKey"),
|
|
2067
|
-
ownerKind: requireNonEmptyString(raw.ownerKind, "ownerKind"),
|
|
2068
|
-
armProvenance: requireArmProvenance(raw.armProvenance),
|
|
2069
|
-
updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
|
|
2070
|
-
};
|
|
2071
|
-
// Same rule as the sender marker, and for the same reason one layer over: an
|
|
2072
|
-
// impossible owner is not a live one, so a refuted marker must never read back
|
|
2073
|
-
// as an ACTIVE RECEIVER and pull the mailbox rail into delivering to a void.
|
|
2074
|
-
if (!isPlausibleOwnerPid(marker.ownerPid)) return null;
|
|
2155
|
+
const marker = parseMetaReceiverMarker(fs.readFileSync(file, "utf8"));
|
|
2156
|
+
if (marker === null) return null;
|
|
2075
2157
|
if (opts.verifyOwner !== false) {
|
|
2076
2158
|
const liveKey = processStartKey(marker.ownerPid);
|
|
2077
2159
|
if (!liveKey || liveKey !== marker.ownerStartKey) return null;
|
|
@@ -2128,6 +2210,13 @@ export function removeMetaReceiverMarker(opts: RemoveMetaReceiverMarkerOptions):
|
|
|
2128
2210
|
|
|
2129
2211
|
export interface UpsertMetaSessionOptions {
|
|
2130
2212
|
input: MetaIdentityMintInput;
|
|
2213
|
+
/**
|
|
2214
|
+
* Refuse, BEFORE any write, unless the decision is this action. The Claude Code hook
|
|
2215
|
+
* creates a record it found absent only with `"create"`: if another writer published one
|
|
2216
|
+
* in between, the decision would silently become an unlocked ATTACH — exactly the write
|
|
2217
|
+
* the record-arm lock exists to keep out (#147 L2 B1).
|
|
2218
|
+
*/
|
|
2219
|
+
expectAction?: UpsertAction;
|
|
2131
2220
|
/** Override the store directory (defaults to {@link defaultMetaSessionsDir}). */
|
|
2132
2221
|
dir?: string;
|
|
2133
2222
|
now?: Date;
|
|
@@ -2175,12 +2264,47 @@ export function upsertMetaSession(opts: UpsertMetaSessionOptions): UpsertMetaSes
|
|
|
2175
2264
|
const target = requireNonEmptyString(opts.input.nativeSessionId, "nativeSessionId");
|
|
2176
2265
|
const existing = cert.records.find((record) => record.identity.nativeSessionId === target)?.identity ?? null;
|
|
2177
2266
|
const decision = decideUpsert(existing, opts.input, opts.now);
|
|
2267
|
+
if (opts.expectAction !== undefined && decision.action !== opts.expectAction) {
|
|
2268
|
+
throw new UpsertActionMismatchError(opts.expectAction, decision.action, target);
|
|
2269
|
+
}
|
|
2178
2270
|
const file = path.join(dir, metaRecordFilename(decision.record));
|
|
2179
2271
|
if (decision.action === "create") publishExclusiveIdentity(file, decision.record);
|
|
2180
2272
|
else atomicWriteIdentity(file, decision.record);
|
|
2181
2273
|
return { action: decision.action, record: decision.record, dir, path: file };
|
|
2182
2274
|
}
|
|
2183
2275
|
|
|
2276
|
+
/** The upsert refused before writing because its decision was not the expected action. */
|
|
2277
|
+
export class UpsertActionMismatchError extends MetaRecordError {
|
|
2278
|
+
readonly expected: UpsertAction;
|
|
2279
|
+
readonly actual: UpsertAction;
|
|
2280
|
+
constructor(expected: UpsertAction, actual: UpsertAction, nativeSessionId: string) {
|
|
2281
|
+
super(
|
|
2282
|
+
`upsert expected to ${expected} the record for nativeSessionId "${nativeSessionId}" but would ${actual} ` +
|
|
2283
|
+
"it; nothing was written.",
|
|
2284
|
+
);
|
|
2285
|
+
this.expected = expected;
|
|
2286
|
+
this.actual = actual;
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/**
|
|
2291
|
+
* The certified record a native session id already has, READ ONLY — the same certification
|
|
2292
|
+
* {@link upsertMetaSession} runs (a defective store throws the same refusal), with no write.
|
|
2293
|
+
* The Claude Code hook learns the garden id it must lock this way, so no record write ever
|
|
2294
|
+
* happens outside the record-arm lock for an existing record (#147 L2 B1).
|
|
2295
|
+
*/
|
|
2296
|
+
export function findCertifiedIdentityByNativeSessionId(
|
|
2297
|
+
nativeSessionId: string,
|
|
2298
|
+
dirOverride?: string,
|
|
2299
|
+
): MetaIdentity | null {
|
|
2300
|
+
const dir = path.resolve(expandTilde(dirOverride ?? defaultMetaSessionsDir()));
|
|
2301
|
+
if (!fs.existsSync(dir)) return null;
|
|
2302
|
+
const cert = certifyActiveStoreDir(dir);
|
|
2303
|
+
if (cert.defects.length > 0) throw new MetaRecordError(activeStoreRefusal(cert));
|
|
2304
|
+
const target = requireNonEmptyString(nativeSessionId, "nativeSessionId");
|
|
2305
|
+
return cert.records.find((record) => record.identity.nativeSessionId === target)?.identity ?? null;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2184
2308
|
/**
|
|
2185
2309
|
* ATTACH replace primitive: tmp-file + rename so a crash never leaves a
|
|
2186
2310
|
* half-written record (#30 crash-safety). rename(2) REPLACES an existing final
|
|
@@ -114,9 +114,11 @@ import {
|
|
|
114
114
|
FRESH_CALL_BACKENDS,
|
|
115
115
|
FRESH_CALL_CALLBACK_TOOL,
|
|
116
116
|
FRESH_CALL_DELIVERY_TOOL,
|
|
117
|
+
FRESH_CALL_PROVIDER_HINT,
|
|
117
118
|
type FreshCallBackend,
|
|
118
119
|
type FreshCallComposition,
|
|
119
120
|
type FreshCallInputRejectReason,
|
|
121
|
+
type FreshCallProviderRejectReason,
|
|
120
122
|
isSafeFreshCallModel,
|
|
121
123
|
MODEL_MAX_CHARS,
|
|
122
124
|
mintNonce,
|
|
@@ -130,6 +132,7 @@ import {
|
|
|
130
132
|
LaunchPreconditionError,
|
|
131
133
|
type LaunchRejectReason,
|
|
132
134
|
resolveRuntimeOnPath,
|
|
135
|
+
SCRUBBED_INHERITED_ENV,
|
|
133
136
|
} from "./mux-launch.ts";
|
|
134
137
|
import {
|
|
135
138
|
APPEND_FORMAT,
|
|
@@ -266,6 +269,7 @@ export type FreshCallRejectReason =
|
|
|
266
269
|
| CodexPreflightRejectReason
|
|
267
270
|
| CodexCallerPreflightRejectReason
|
|
268
271
|
| CodexCallerSeatRejectReason
|
|
272
|
+
| FreshCallProviderRejectReason
|
|
269
273
|
| "acp-model-not-curated"
|
|
270
274
|
| "caller-identity-unavailable"
|
|
271
275
|
| "model-empty"
|
|
@@ -347,33 +351,8 @@ export interface FreshCallReceipt extends WindowHandle {
|
|
|
347
351
|
|
|
348
352
|
export type FreshCallResult = { ok: true; receipt: FreshCallReceipt } | { ok: false; reason: FreshCallRejectReason };
|
|
349
353
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
*
|
|
353
|
-
* `[측정]` 2026-08-30, private tmux server: a `new-window` pane inherits the tmux SERVER's
|
|
354
|
-
* environment, not the caller's. A server started from a shell that exported `PI_SESSION_ID`
|
|
355
|
-
* hands that value to every window it will ever open — the control run printed
|
|
356
|
-
* `SID=[leaked-uuid]` in a pane the caller never touched. Nothing about the fresh call creates
|
|
357
|
-
* that leak and nothing about it notices: the sibling's own MCP child would read the STALE pair
|
|
358
|
-
* as its authoritative identity and call home as a citizen it is not (`mcp/entwurf-bridge/
|
|
359
|
-
* src/index.ts:692-698` keeps the measured incident — a fresh cell answering with the uuidv7 it
|
|
360
|
-
* found in the environment, confidently and wrong).
|
|
361
|
-
*
|
|
362
|
-
* `-e VAR=` sets the variable EMPTY rather than unsetting it, which tmux has no per-window form
|
|
363
|
-
* for. That is sufficient and not a compromise: every reader of the carrier trims and tests
|
|
364
|
-
* truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
|
|
365
|
-
*
|
|
366
|
-
* It is applied to all six backends because the leak is a property of tmux, not of a vendor. A
|
|
367
|
-
* scrub only on the backend whose measurement surfaced it would encode the claim that the other
|
|
368
|
-
* five are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
|
|
369
|
-
* authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
|
|
370
|
-
* exactly that after this argv has run. This is a fixed seam and deliberately NOT a general env
|
|
371
|
-
* carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping power this
|
|
372
|
-
* rail exists to refuse. Two more `-e` assignments ride beside the scrub: the callback pair
|
|
373
|
-
* (`callback-env.ts`), launcher-computed, so the sibling's first action does not retype an
|
|
374
|
-
* address out of prose.
|
|
375
|
-
*/
|
|
376
|
-
const SCRUBBED_INHERITED_ENV = ["PI_SESSION_ID=", "PI_AGENT_ID="] as const;
|
|
354
|
+
// The identity scrub every fresh window carries is `SCRUBBED_INHERITED_ENV` (mux-launch.ts), shared
|
|
355
|
+
// with the resume rail because the leak it closes is a property of tmux, not of either verb.
|
|
377
356
|
|
|
378
357
|
/** Launch argv: the leaf's detached-append shape, the identity scrub, optionally `-c` at the
|
|
379
358
|
* resume-symmetric token position (after `-t`, before `-P -F`), the runtime, then the backend's
|
|
@@ -661,6 +640,7 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
|
|
|
661
640
|
...CODEX_PREFLIGHT_HINT,
|
|
662
641
|
...CODEX_CALLER_PREFLIGHT_HINT,
|
|
663
642
|
...CODEX_CALLER_SEAT_HINT,
|
|
643
|
+
...FRESH_CALL_PROVIDER_HINT,
|
|
664
644
|
"no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
|
|
665
645
|
"anchor-malformed": "TMUX_PANE is not a native pane id",
|
|
666
646
|
"anchor-unresolved": "tmux resolved no pane for this agent's anchor",
|
|
@@ -71,6 +71,35 @@ import {
|
|
|
71
71
|
* is a separate ownership decision (docs §11), not a parameter. */
|
|
72
72
|
export const PI_RUNTIME_COMMAND = "pi";
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The pi identity carrier, scrubbed at the launch seam for EVERY backend (#87 Bundle C).
|
|
76
|
+
*
|
|
77
|
+
* `[측정]` 2026-08-30, private tmux server: a `new-window` pane inherits the tmux SERVER's
|
|
78
|
+
* environment, not the caller's. A server started from a shell that exported `PI_SESSION_ID`
|
|
79
|
+
* hands that value to every window it will ever open — the control run printed
|
|
80
|
+
* `SID=[leaked-uuid]` in a pane the caller never touched. Nothing about the fresh call creates
|
|
81
|
+
* that leak and nothing about it notices: the sibling's own MCP child would read the STALE pair
|
|
82
|
+
* as its authoritative identity and call home as a citizen it is not (`mcp/entwurf-bridge/
|
|
83
|
+
* src/index.ts:692-698` keeps the measured incident — a fresh cell answering with the uuidv7 it
|
|
84
|
+
* found in the environment, confidently and wrong).
|
|
85
|
+
*
|
|
86
|
+
* `-e VAR=` sets the variable EMPTY rather than unsetting it, which tmux has no per-window form
|
|
87
|
+
* for. That is sufficient and not a compromise: every reader of the carrier trims and tests
|
|
88
|
+
* truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
|
|
89
|
+
*
|
|
90
|
+
* It is applied to every fresh backend and to every resumed one (#147 L2) because the leak is a
|
|
91
|
+
* property of tmux, not of a vendor or a verb. A
|
|
92
|
+
* scrub only on the backend whose measurement surfaced it would encode the claim that the other
|
|
93
|
+
* five are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
|
|
94
|
+
* authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
|
|
95
|
+
* exactly that after this argv has run. This is a fixed seam and deliberately NOT a general env
|
|
96
|
+
* carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping power this
|
|
97
|
+
* rail exists to refuse. Two more `-e` assignments ride beside the scrub: the callback pair
|
|
98
|
+
* (`callback-env.ts`), launcher-computed, so the sibling's first action does not retype an
|
|
99
|
+
* address out of prose.
|
|
100
|
+
*/
|
|
101
|
+
export const SCRUBBED_INHERITED_ENV = ["PI_SESSION_ID=", "PI_AGENT_ID="] as const;
|
|
102
|
+
|
|
74
103
|
/**
|
|
75
104
|
* Why a launch was refused BEFORE any window was opened. Each is a named refusal, never a
|
|
76
105
|
* fallback — a caller that cannot start the official runtime must not be handed a window.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mux-resume-call — open ONE window in the caller's own tmux session at a cwd this module did
|
|
3
|
-
* not choose, and hand it argv this module did not author.
|
|
3
|
+
* not choose, start the backend's FIXED runtime there, and hand it argv this module did not author.
|
|
4
4
|
*
|
|
5
5
|
* ── Why this is a module and not a parameter on fresh-call ──
|
|
6
6
|
*
|
|
@@ -15,7 +15,15 @@
|
|
|
15
15
|
* visible-resume composition → resume-call → placement leaf (unchanged, carrier-free)
|
|
16
16
|
* resume-call -X-> garden identity, records, locks, delivery
|
|
17
17
|
*
|
|
18
|
-
* This module never learns which citizen it is reopening. It sees a cwd and an argv
|
|
18
|
+
* This module never learns which citizen it is reopening. It sees a backend, a cwd and an argv
|
|
19
|
+
* tail; the backend only selects a runtime from a closed table (#147 L2).
|
|
20
|
+
*
|
|
21
|
+
* ── Tmux only, and said so inside herdr ──
|
|
22
|
+
*
|
|
23
|
+
* This is the tmux rail. A caller inside herdr (`HERDR_ENV=1`, herdr's own marker, read for its
|
|
24
|
+
* exact value as `fresh-call-dispatch` does) is refused by name before anything else: herdr owns
|
|
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. There is no herdr resume rail yet and no fallback.
|
|
19
27
|
*
|
|
20
28
|
* ── The cwd is the whole reason this module owns its own tmux argv ──
|
|
21
29
|
*
|
|
@@ -47,6 +55,7 @@ import {
|
|
|
47
55
|
LaunchPreconditionError,
|
|
48
56
|
type LaunchRejectReason,
|
|
49
57
|
resolveRuntimeOnPath,
|
|
58
|
+
SCRUBBED_INHERITED_ENV,
|
|
50
59
|
} from "./mux-launch.ts";
|
|
51
60
|
import {
|
|
52
61
|
APPEND_FORMAT,
|
|
@@ -61,15 +70,20 @@ import {
|
|
|
61
70
|
type WindowHandle,
|
|
62
71
|
} from "./mux-placement.ts";
|
|
63
72
|
|
|
64
|
-
/** The fixed runtime
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
export const RESUME_CALL_RUNTIME = "pi";
|
|
73
|
+
/** The fixed runtime each resumable backend reopens with. A closed table, not a command input: the
|
|
74
|
+
* backend boundary is enforced upstream by `resolveResumeLaunchIdentity`, and this is the mux half
|
|
75
|
+
* of the same fact. */
|
|
76
|
+
export const RESUME_CALL_RUNTIME = { pi: "pi", "claude-code": "claude" } as const;
|
|
77
|
+
export type ResumeCallBackend = keyof typeof RESUME_CALL_RUNTIME;
|
|
68
78
|
|
|
69
79
|
/** Why a resume window could not be opened. Every value is a NAMED refusal; this module has no
|
|
70
80
|
* fallback launch and no fallback directory. The cwd members come from the shared classification
|
|
71
81
|
* leaf and their string values are stable contract. */
|
|
72
|
-
export type ResumeCallRejectReason =
|
|
82
|
+
export type ResumeCallRejectReason =
|
|
83
|
+
| PlacementRejectReason
|
|
84
|
+
| LaunchRejectReason
|
|
85
|
+
| TmuxCwdRejectReason
|
|
86
|
+
| "herdr-resume-unsupported";
|
|
73
87
|
|
|
74
88
|
/** Coordinates plus what was handed to tmux. `cwd` is the REQUESTED start directory — the same
|
|
75
89
|
* kind of fact as `runtimePath`, namely what tmux was asked for, not an observation. */
|
|
@@ -81,8 +95,11 @@ export interface ResumeCallReceipt extends WindowHandle {
|
|
|
81
95
|
export type ResumeCallResult = { ok: true; receipt: ResumeCallReceipt } | { ok: false; reason: ResumeCallRejectReason };
|
|
82
96
|
|
|
83
97
|
/**
|
|
84
|
-
* Launch argv: the leaf's detached-append shape
|
|
85
|
-
*
|
|
98
|
+
* Launch argv: the leaf's detached-append shape, the fixed identity scrub (`SCRUBBED_INHERITED_ENV`,
|
|
99
|
+
* the same two assignments every fresh window carries), `-c`, the runtime, then the caller's flags.
|
|
100
|
+
* `--` is what keeps tmux from reading the runtime or its flags as tmux options. The scrub is not a
|
|
101
|
+
* carrier: it only empties identity a tmux server may have inherited, and a resumed Claude Code
|
|
102
|
+
* (which, unlike pi, never re-exports its own) would otherwise present a stale one to its bridge.
|
|
86
103
|
*/
|
|
87
104
|
export function buildResumeCallArgs(
|
|
88
105
|
placement: Placement,
|
|
@@ -98,6 +115,7 @@ export function buildResumeCallArgs(
|
|
|
98
115
|
"new-window",
|
|
99
116
|
"-d",
|
|
100
117
|
"-a",
|
|
118
|
+
...SCRUBBED_INHERITED_ENV.flatMap((assignment) => ["-e", assignment]),
|
|
101
119
|
"-t",
|
|
102
120
|
`${placement.sessionId}:{end}`,
|
|
103
121
|
"-c",
|
|
@@ -119,15 +137,16 @@ export function buildResumeCallArgs(
|
|
|
119
137
|
* `runtimeArgs` is passed through untouched. This module does not know what `--session` means.
|
|
120
138
|
*/
|
|
121
139
|
export function resumeCall(
|
|
122
|
-
params: { cwd: string; runtimeArgs: readonly string[] },
|
|
140
|
+
params: { backend: ResumeCallBackend; cwd: string; runtimeArgs: readonly string[] },
|
|
123
141
|
env: NodeJS.ProcessEnv = process.env,
|
|
124
142
|
): ResumeCallResult {
|
|
143
|
+
if (env.HERDR_ENV === "1") return { ok: false, reason: "herdr-resume-unsupported" };
|
|
125
144
|
const badCwd = classifyTmuxCwd(params.cwd);
|
|
126
145
|
if (badCwd) return { ok: false, reason: badCwd };
|
|
127
146
|
|
|
128
147
|
let runtimePath: string;
|
|
129
148
|
try {
|
|
130
|
-
runtimePath = resolveRuntimeOnPath(RESUME_CALL_RUNTIME, env);
|
|
149
|
+
runtimePath = resolveRuntimeOnPath(RESUME_CALL_RUNTIME[params.backend], env);
|
|
131
150
|
} catch (err) {
|
|
132
151
|
if (err instanceof LaunchPreconditionError) return { ok: false, reason: err.reason };
|
|
133
152
|
throw err;
|
|
@@ -180,7 +199,9 @@ export const RESUME_CALL_REJECT_HINT: Record<ResumeCallRejectReason, string> = {
|
|
|
180
199
|
"cwd-missing":
|
|
181
200
|
"the recorded cwd no longer exists; tmux would not report this, it would open the window in $HOME and look successful",
|
|
182
201
|
"cwd-not-directory": "the recorded cwd exists but is not a directory",
|
|
183
|
-
"
|
|
202
|
+
"herdr-resume-unsupported":
|
|
203
|
+
"this agent runs inside herdr, and same-id resume has only a tmux rail — nothing was opened in any ambient tmux session",
|
|
204
|
+
"runtime-unresolved": "the citizen's runtime (pi or claude) is not installed on PATH",
|
|
184
205
|
"runtime-not-absolute": "the resolved runtime path is not absolute",
|
|
185
206
|
"runtime-path-whitespace": "the resolved runtime path contains whitespace, which tmux would re-split",
|
|
186
207
|
"runtime-missing": "nothing exists at the resolved runtime path",
|