@junghanacs/entwurf 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +60 -5
  3. package/CHANGELOG.md +557 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +52 -2
  6. package/FAQ.md +8 -4
  7. package/README.md +37 -17
  8. package/VERIFY.md +23 -10
  9. package/docs/acp-backend-rail.md +5 -1
  10. package/docs/external-mcp-host.md +8 -6
  11. package/docs/fresh-cut-policy.md +9 -1
  12. package/docs/setup-clean-host.md +64 -9
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +100 -24
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +10 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +123 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +259 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js +80 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
  36. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
  37. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  38. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  39. package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +65 -0
  40. package/mcp/entwurf-bridge/src/index.ts +118 -24
  41. package/mcp/entwurf-bridge/test.sh +2 -2
  42. package/mcp/entwurf-bridge/tsconfig.build.json +17 -1
  43. package/package.json +5 -5
  44. package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
  45. package/pi-extensions/entwurf-control.ts +55 -20
  46. package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  47. package/pi-extensions/lib/cc-retire-request.ts +553 -0
  48. package/pi-extensions/lib/control-retire.ts +195 -0
  49. package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
  50. package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
  51. package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
  52. package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
  53. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
  54. package/pi-extensions/lib/fresh-call-composition.ts +15 -0
  55. package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
  56. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  57. package/pi-extensions/lib/meta-mailbox-body.ts +13 -4
  58. package/pi-extensions/lib/meta-session.ts +140 -16
  59. package/pi-extensions/lib/mux-fresh-call.ts +7 -27
  60. package/pi-extensions/lib/mux-launch.ts +29 -0
  61. package/pi-extensions/lib/mux-resume-call.ts +33 -12
  62. package/pi-extensions/lib/pi-retire-request.ts +111 -0
  63. package/pi-extensions/lib/remote-pair-receive.ts +184 -0
  64. package/pi-extensions/lib/remote-pair-send.ts +325 -0
  65. package/pi-extensions/lib/remote-pair-transport.ts +81 -0
  66. package/pi-extensions/lib/resume-launch-identity.ts +104 -18
  67. package/pi-extensions/lib/retirement-event.ts +253 -0
  68. package/pi-extensions/meta-bridge-hook.ts +142 -13
  69. package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
  70. package/run.sh +122 -7
  71. package/scripts/agy-bridge-config.py +4 -1
  72. package/scripts/build-bridge.sh +14 -0
  73. package/scripts/check-capability-bundle-reach.ts +135 -2
  74. package/scripts/check-entwurf-bridge-boot.ts +2 -1
  75. package/scripts/check-entwurf-fact-provider.ts +1 -0
  76. package/scripts/check-entwurf-v2-production.ts +2 -0
  77. package/scripts/check-entwurf-v2-surface.ts +7 -0
  78. package/scripts/check-gate-qualification.ts +20 -8
  79. package/scripts/check-herdr-activation.ts +2 -2
  80. package/scripts/check-herdr-placement.ts +1 -0
  81. package/scripts/check-hook-launch-topology.ts +6 -15
  82. package/scripts/check-install-container.sh +2 -2
  83. package/scripts/check-meta-doctor-oracle.sh +27 -1
  84. package/scripts/check-meta-facts.ts +8 -3
  85. package/scripts/check-meta-hook-session-switch.ts +230 -13
  86. package/scripts/check-meta-identity-consumers.ts +1 -0
  87. package/scripts/check-meta-listing.ts +1 -0
  88. package/scripts/check-meta-manifest-schema.py +1 -1
  89. package/scripts/check-meta-session.ts +31 -0
  90. package/scripts/check-meta-v3-record.ts +55 -1
  91. package/scripts/check-mux-resume-call.ts +35 -9
  92. package/scripts/check-peer-facts.ts +1 -0
  93. package/scripts/check-pi-durable-contact.ts +3 -1
  94. package/scripts/check-pi-durable-send.ts +4 -2
  95. package/scripts/check-pi-mcp-bridge.ts +1 -0
  96. package/scripts/check-remote-pair-installed.ts +242 -0
  97. package/scripts/check-resume-launch-identity.ts +62 -23
  98. package/scripts/lib/meta-hook-bundle.ts +72 -0
  99. package/scripts/lib/remote-pair-installed-audit.ts +25 -0
  100. package/scripts/meta-bridge-claude-floor.sh +13 -9
  101. package/scripts/meta-bridge-doctor.sh +17 -0
  102. package/scripts/meta-bridge-install.sh +6 -0
  103. package/scripts/meta-facts.ts +1 -1
  104. package/scripts/mutants/bridge-boot-resume.json +5 -2
  105. package/scripts/mutants/cc-retire-pidfd.json +129 -0
  106. package/scripts/mutants/cc-retire-request.json +171 -0
  107. package/scripts/mutants/control-retire.json +105 -0
  108. package/scripts/mutants/control-send-receipt.json +2 -2
  109. package/scripts/mutants/copilot-receive.json +2 -6
  110. package/scripts/mutants/entwurf-v2-retire.json +476 -0
  111. package/scripts/mutants/fresh-call-dispatch.json +37 -0
  112. package/scripts/mutants/meta-hook-session-switch.json +88 -0
  113. package/scripts/mutants/meta-identity.json +57 -0
  114. package/scripts/mutants/mux-fresh-call.json +1 -1
  115. package/scripts/mutants/mux-resume-call.json +40 -4
  116. package/scripts/mutants/pi-durable.json +79 -0
  117. package/scripts/mutants/pi-retire-request.json +72 -0
  118. package/scripts/mutants/remote-pair-display.json +53 -0
  119. package/scripts/mutants/remote-pair-installed-audit.json +28 -0
  120. package/scripts/mutants/remote-pair-receive.json +126 -0
  121. package/scripts/mutants/remote-pair-roundtrip.json +83 -0
  122. package/scripts/mutants/remote-pair-send.json +194 -0
  123. package/scripts/mutants/remote-pair-transport.json +66 -0
  124. package/scripts/mutants/resume-launch-identity.json +35 -8
  125. package/scripts/mutants/retirement-event.json +195 -0
  126. package/scripts/mutants/v2-surface.json +16 -0
  127. package/scripts/mutants/v2-visible-resume.json +283 -13
  128. package/scripts/probe-bridge-command.ts +1 -0
  129. package/scripts/remote-pair-receive.ts +73 -0
  130. package/scripts/smoke-agy-install-state.sh +2 -2
  131. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  132. package/scripts/smoke-herdr-raw-install-live.sh +5 -4
  133. package/scripts/smoke-meta-install-state.sh +4 -4
  134. package/scripts/smoke-mux-lifecycle-live.ts +10 -10
  135. package/scripts/smoke-omp-mcp-state.sh +1 -1
  136. package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
  137. 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 register
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. So the
10
- * surfaces now supply identity and an env, and this module supplies everything else.
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",
@@ -35,6 +35,9 @@ export interface MailboxSenderEnvelope {
35
35
  timestamp: string; // ISO 8601 UTC
36
36
  origin?: "pi-session" | "external-mcp" | "meta-session";
37
37
  replyable?: boolean;
38
+ /** #107: set only by the receiving half of a declared remote pair. `via` is the route label;
39
+ * `replyRoute` says whether this host declares a route back. Neither is a probe. */
40
+ remote?: { via: string; replyRoute: "declared" | "none" };
38
41
  }
39
42
 
40
43
  /** `~`-abbreviate a home-relative cwd for display. Reads process.env.HOME. */
@@ -72,14 +75,20 @@ export function formatMetaMailboxBody(sender: MailboxSenderEnvelope, message: st
72
75
  const replyable = sender.replyable === true;
73
76
  const isMeta = sender.origin === "meta-session";
74
77
  const kind = isMeta ? "meta-session, " : "";
78
+ // A remote sender is never replyable here: no reply route was probed. Its cwd is a path on the
79
+ // SENDING host, so it is neither abbreviated against this host's HOME nor shown as a local path.
80
+ const remote = !replyable ? sender.remote : undefined;
75
81
  const sessionLine = replyable
76
82
  ? `${sender.sessionId} (${kind}replyable — reply via entwurf_v2 to this sessionId, intent=fire-and-forget)`
77
- : isMeta
78
- ? `${sender.sessionId} (meta-session, non-replyable)`
79
- : `${sender.sessionId} (external, non-replyable)`;
83
+ : remote
84
+ ? `${sender.sessionId} (remote via ${remote.via}; ${remote.replyRoute === "declared" ? "reply route declared, not probed" : "no reply route declared"})`
85
+ : isMeta
86
+ ? `${sender.sessionId} (meta-session, non-replyable)`
87
+ : `${sender.sessionId} (external, non-replyable)`;
88
+ const where = remote ? `${sender.cwd} (sender-host path)` : abbreviateHome(sender.cwd);
80
89
  return (
81
90
  `[entwurf received ⟵]\n` +
82
- ` from: ${sender.agentId} @ ${abbreviateHome(sender.cwd)}\n` +
91
+ ` from: ${sender.agentId} @ ${where}\n` +
83
92
  ` session: ${sessionLine}\n` +
84
93
  ` at: ${formatKstTimestamp(sender.timestamp)}\n` +
85
94
  ` wants reply: ${wantsReply ? "yes" : "no"}\n` +
@@ -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 raw = JSON.parse(fs.readFileSync(file, "utf8"));
2061
- const marker: MetaReceiverMarker = {
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
- * The pi identity carrier, scrubbed at the launch seam for EVERY backend (#87 Bundle C).
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 tail.
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 a resume reopens. Only pi stands a control socket up, so only pi has a
65
- * same-id resume at all — the backend boundary is enforced upstream by
66
- * `resolveResumeLaunchIdentity`, and this constant is the mux half of the same fact. */
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 = PlacementRejectReason | LaunchRejectReason | TmuxCwdRejectReason;
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 plus `-c`, the runtime, then the caller's flags.
85
- * `--` is what keeps tmux from reading the runtime or its flags as tmux options.
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
- "runtime-unresolved": "pi is not installed on PATH",
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",