@junghanacs/entwurf 0.40.0 → 0.41.1
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 +562 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +18 -2
- package/FAQ.md +8 -4
- package/README.md +60 -45
- package/VERIFY.md +14 -11
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +6 -2
- package/docs/external-mcp-host.md +8 -6
- package/docs/fresh-cut-policy.md +9 -1
- package/docs/setup-clean-host.md +76 -20
- 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 +15 -14
- package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
- package/pi/pi-durable/carrier/tui.js +18 -3
- package/pi/pi-durable/overlay/upstream-pin.json +12 -12
- package/pi-extensions/entwurf-control.ts +55 -20
- package/pi-extensions/lib/acp/backend-adapter.ts +5 -3
- package/pi-extensions/lib/acp/backend.ts +12 -9
- 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 +57 -3
- package/run.sh +123 -34
- 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/codex-fresh-live-protocol.ts +3 -2
- 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/control-send-receipt.json +1 -1
- 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/pack-install.json +4 -4
- package/scripts/mutants/pi-durable.json +139 -0
- package/scripts/mutants/pi-floor.json +5 -5
- 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-codex-fresh-live.ts +2 -1
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +15 -7
- 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
|
@@ -396,7 +396,23 @@ const META_IDENTITY_KEYS = [
|
|
|
396
396
|
"transcriptPath",
|
|
397
397
|
"createdAt",
|
|
398
398
|
"recordUpdatedAt",
|
|
399
|
+
"attachIncarnation",
|
|
399
400
|
];
|
|
401
|
+
/** `attachIncarnation` grammar: 32 lowercase hex characters (`randomBytes(16)`). */
|
|
402
|
+
const ATTACH_INCARNATION_RE = /^[0-9a-f]{32}$/;
|
|
403
|
+
/** Validate an `attachIncarnation`: null, or the 32-hex grammar. Throws on anything else. */
|
|
404
|
+
export function requireAttachIncarnation(value) {
|
|
405
|
+
if (value === null)
|
|
406
|
+
return null;
|
|
407
|
+
if (typeof value !== "string" || !ATTACH_INCARNATION_RE.test(value)) {
|
|
408
|
+
throw new MetaRecordError(`meta-record field "attachIncarnation" must be null or 32 lowercase hex characters (got ${describe(value)}).`);
|
|
409
|
+
}
|
|
410
|
+
return value;
|
|
411
|
+
}
|
|
412
|
+
/** Mint one arm binding value. Clock-independent: 128 random bits, never derived from time. */
|
|
413
|
+
export function mintAttachIncarnation() {
|
|
414
|
+
return crypto.randomBytes(16).toString("hex");
|
|
415
|
+
}
|
|
400
416
|
/** Parse + fully validate untrusted JSON into a v3 MetaIdentity. Throws on any drift. */
|
|
401
417
|
export function parseMetaRecordV3(json) {
|
|
402
418
|
let raw;
|
|
@@ -432,6 +448,10 @@ export function parseMetaRecordV3(json) {
|
|
|
432
448
|
transcriptPath: requireNullableString(obj.transcriptPath, "transcriptPath"),
|
|
433
449
|
createdAt: requireNonEmptyString(obj.createdAt, "createdAt"),
|
|
434
450
|
recordUpdatedAt: requireNonEmptyString(obj.recordUpdatedAt, "recordUpdatedAt"),
|
|
451
|
+
// The one key a V3 record may omit: a record nothing has touched since the field
|
|
452
|
+
// existed reads its absence as null ("no binding observed"). One schema, one reader —
|
|
453
|
+
// not a legacy alias: every serialize emits the key.
|
|
454
|
+
attachIncarnation: requireAttachIncarnation(obj.attachIncarnation === undefined ? null : obj.attachIncarnation),
|
|
435
455
|
};
|
|
436
456
|
}
|
|
437
457
|
/**
|
|
@@ -450,6 +470,7 @@ export function normalizeMetaIdentity(record) {
|
|
|
450
470
|
transcriptPath: record.transcriptPath,
|
|
451
471
|
createdAt: record.createdAt,
|
|
452
472
|
recordUpdatedAt: record.recordUpdatedAt,
|
|
473
|
+
attachIncarnation: record.attachIncarnation,
|
|
453
474
|
};
|
|
454
475
|
}
|
|
455
476
|
// ---------------------------------------------------------------------------
|
|
@@ -474,6 +495,7 @@ export function serializeMetaIdentity(identity) {
|
|
|
474
495
|
transcriptPath: identity.transcriptPath,
|
|
475
496
|
createdAt: identity.createdAt,
|
|
476
497
|
recordUpdatedAt: identity.recordUpdatedAt,
|
|
498
|
+
attachIncarnation: identity.attachIncarnation,
|
|
477
499
|
};
|
|
478
500
|
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
479
501
|
}
|
|
@@ -497,6 +519,7 @@ export function mintMetaIdentity(input, now = new Date()) {
|
|
|
497
519
|
transcriptPath: requireNullableString(input.transcriptPath ?? null, "transcriptPath"),
|
|
498
520
|
createdAt: ts,
|
|
499
521
|
recordUpdatedAt: ts,
|
|
522
|
+
attachIncarnation: input.attachIncarnation === undefined ? null : requireAttachIncarnation(input.attachIncarnation),
|
|
500
523
|
};
|
|
501
524
|
}
|
|
502
525
|
/**
|
|
@@ -1097,9 +1120,13 @@ export function decideUpsert(existing, input, now = new Date()) {
|
|
|
1097
1120
|
const transcriptPath = input.transcriptPath === undefined
|
|
1098
1121
|
? existing.transcriptPath
|
|
1099
1122
|
: requireNullableString(input.transcriptPath, "transcriptPath");
|
|
1123
|
+
// The arm binding is not a merge axis: only a mint replaces it, and nothing clears it.
|
|
1124
|
+
const attachIncarnation = input.attachIncarnation === undefined
|
|
1125
|
+
? existing.attachIncarnation
|
|
1126
|
+
: requireAttachIncarnation(input.attachIncarnation);
|
|
1100
1127
|
return {
|
|
1101
1128
|
action: "attach",
|
|
1102
|
-
record: { ...existing, cwd, model, transcriptPath, recordUpdatedAt: isoNow(now) },
|
|
1129
|
+
record: { ...existing, cwd, model, transcriptPath, recordUpdatedAt: isoNow(now), attachIncarnation },
|
|
1103
1130
|
};
|
|
1104
1131
|
}
|
|
1105
1132
|
// ---------------------------------------------------------------------------
|
|
@@ -1196,6 +1223,16 @@ export function defaultMetaReceiversDir() {
|
|
|
1196
1223
|
return path.resolve(expandTilde(process.env.ENTWURF_META_RECEIVERS_DIR));
|
|
1197
1224
|
return path.join(piAgentDir(), "meta-receivers");
|
|
1198
1225
|
}
|
|
1226
|
+
/**
|
|
1227
|
+
* The record-arm lock directory (#147 L2 B1): a sibling of the receivers dir, so a
|
|
1228
|
+
* sandboxed receivers root sandboxes it too and no new environment carrier exists. It is
|
|
1229
|
+
* deliberately NOT the v2 dispatch lock dir — visible resume holds that per-gid lock across
|
|
1230
|
+
* its whole launch + observation window, and the relaunched session's own arm must not wait
|
|
1231
|
+
* on it. Only the Claude Code hook takes this lock; nothing else reads or writes here.
|
|
1232
|
+
*/
|
|
1233
|
+
export function defaultMetaRecordArmLocksDir(receiversDir = defaultMetaReceiversDir()) {
|
|
1234
|
+
return path.join(path.dirname(receiversDir), "meta-record-arm-locks");
|
|
1235
|
+
}
|
|
1199
1236
|
/**
|
|
1200
1237
|
* The exact provenance label the omp-native MCP entry carries
|
|
1201
1238
|
* (`scripts/omp-mcp-config.py` `EXTERNAL_AGENT_ID`). It is what lets a bridge CHILD know
|
|
@@ -1652,12 +1689,38 @@ export function writeMetaReceiverMarker(opts) {
|
|
|
1652
1689
|
ownerKind: requireNonEmptyString(opts.ownerKind ?? "claude-code-cli", "ownerKind"),
|
|
1653
1690
|
armProvenance: requireArmProvenance(opts.armProvenance),
|
|
1654
1691
|
updatedAt: isoNow(opts.now ?? new Date()),
|
|
1692
|
+
attachIncarnation: opts.attachIncarnation === undefined ? null : requireAttachIncarnation(opts.attachIncarnation),
|
|
1655
1693
|
};
|
|
1656
1694
|
const tmp = `${file}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
1657
1695
|
fs.writeFileSync(tmp, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
|
|
1658
1696
|
fs.renameSync(tmp, file);
|
|
1659
1697
|
return file;
|
|
1660
1698
|
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Validate receiver-marker bytes — the one validator, shared by the path reader below and by
|
|
1701
|
+
* the no-follow fd reader visible resume uses (#147 L2 D3). Throws on a malformed marker;
|
|
1702
|
+
* returns null for an IMPLAUSIBLE owner pid. Same rule as the sender marker, and for the same
|
|
1703
|
+
* reason one layer over: an impossible owner is not a live one, so a refuted marker must never
|
|
1704
|
+
* read back as an ACTIVE RECEIVER and pull the mailbox rail into delivering to a void. An
|
|
1705
|
+
* absent `attachIncarnation` reads as null; a present malformed one throws.
|
|
1706
|
+
*/
|
|
1707
|
+
export function parseMetaReceiverMarker(json) {
|
|
1708
|
+
const raw = JSON.parse(json);
|
|
1709
|
+
const marker = {
|
|
1710
|
+
gardenId: requireGardenId(raw.gardenId),
|
|
1711
|
+
backend: requireBackend(raw.backend),
|
|
1712
|
+
nativeSessionId: requireNonEmptyString(raw.nativeSessionId, "nativeSessionId"),
|
|
1713
|
+
ownerPid: typeof raw.ownerPid === "number" ? raw.ownerPid : Number.NaN,
|
|
1714
|
+
ownerStartKey: requireNonEmptyString(raw.ownerStartKey, "ownerStartKey"),
|
|
1715
|
+
ownerKind: requireNonEmptyString(raw.ownerKind, "ownerKind"),
|
|
1716
|
+
armProvenance: requireArmProvenance(raw.armProvenance),
|
|
1717
|
+
updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
|
|
1718
|
+
attachIncarnation: requireAttachIncarnation(raw.attachIncarnation === undefined ? null : raw.attachIncarnation),
|
|
1719
|
+
};
|
|
1720
|
+
if (!isPlausibleOwnerPid(marker.ownerPid))
|
|
1721
|
+
return null;
|
|
1722
|
+
return marker;
|
|
1723
|
+
}
|
|
1661
1724
|
/**
|
|
1662
1725
|
* Read the receiver presence marker for a garden id. Returns null when absent,
|
|
1663
1726
|
* corrupt, or (under verifyOwner) the owner pid is no longer the same live process —
|
|
@@ -1674,21 +1737,8 @@ export function readMetaReceiverMarker(opts) {
|
|
|
1674
1737
|
if (!file || !fs.existsSync(file))
|
|
1675
1738
|
return null;
|
|
1676
1739
|
try {
|
|
1677
|
-
const
|
|
1678
|
-
|
|
1679
|
-
gardenId: requireGardenId(raw.gardenId),
|
|
1680
|
-
backend: requireBackend(raw.backend),
|
|
1681
|
-
nativeSessionId: requireNonEmptyString(raw.nativeSessionId, "nativeSessionId"),
|
|
1682
|
-
ownerPid: typeof raw.ownerPid === "number" ? raw.ownerPid : Number.NaN,
|
|
1683
|
-
ownerStartKey: requireNonEmptyString(raw.ownerStartKey, "ownerStartKey"),
|
|
1684
|
-
ownerKind: requireNonEmptyString(raw.ownerKind, "ownerKind"),
|
|
1685
|
-
armProvenance: requireArmProvenance(raw.armProvenance),
|
|
1686
|
-
updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
|
|
1687
|
-
};
|
|
1688
|
-
// Same rule as the sender marker, and for the same reason one layer over: an
|
|
1689
|
-
// impossible owner is not a live one, so a refuted marker must never read back
|
|
1690
|
-
// as an ACTIVE RECEIVER and pull the mailbox rail into delivering to a void.
|
|
1691
|
-
if (!isPlausibleOwnerPid(marker.ownerPid))
|
|
1740
|
+
const marker = parseMetaReceiverMarker(fs.readFileSync(file, "utf8"));
|
|
1741
|
+
if (marker === null)
|
|
1692
1742
|
return null;
|
|
1693
1743
|
if (opts.verifyOwner !== false) {
|
|
1694
1744
|
const liveKey = processStartKey(marker.ownerPid);
|
|
@@ -1774,6 +1824,9 @@ export function upsertMetaSession(opts) {
|
|
|
1774
1824
|
const target = requireNonEmptyString(opts.input.nativeSessionId, "nativeSessionId");
|
|
1775
1825
|
const existing = cert.records.find((record) => record.identity.nativeSessionId === target)?.identity ?? null;
|
|
1776
1826
|
const decision = decideUpsert(existing, opts.input, opts.now);
|
|
1827
|
+
if (opts.expectAction !== undefined && decision.action !== opts.expectAction) {
|
|
1828
|
+
throw new UpsertActionMismatchError(opts.expectAction, decision.action, target);
|
|
1829
|
+
}
|
|
1777
1830
|
const file = path.join(dir, metaRecordFilename(decision.record));
|
|
1778
1831
|
if (decision.action === "create")
|
|
1779
1832
|
publishExclusiveIdentity(file, decision.record);
|
|
@@ -1781,6 +1834,33 @@ export function upsertMetaSession(opts) {
|
|
|
1781
1834
|
atomicWriteIdentity(file, decision.record);
|
|
1782
1835
|
return { action: decision.action, record: decision.record, dir, path: file };
|
|
1783
1836
|
}
|
|
1837
|
+
/** The upsert refused before writing because its decision was not the expected action. */
|
|
1838
|
+
export class UpsertActionMismatchError extends MetaRecordError {
|
|
1839
|
+
expected;
|
|
1840
|
+
actual;
|
|
1841
|
+
constructor(expected, actual, nativeSessionId) {
|
|
1842
|
+
super(`upsert expected to ${expected} the record for nativeSessionId "${nativeSessionId}" but would ${actual} ` +
|
|
1843
|
+
"it; nothing was written.");
|
|
1844
|
+
this.expected = expected;
|
|
1845
|
+
this.actual = actual;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* The certified record a native session id already has, READ ONLY — the same certification
|
|
1850
|
+
* {@link upsertMetaSession} runs (a defective store throws the same refusal), with no write.
|
|
1851
|
+
* The Claude Code hook learns the garden id it must lock this way, so no record write ever
|
|
1852
|
+
* happens outside the record-arm lock for an existing record (#147 L2 B1).
|
|
1853
|
+
*/
|
|
1854
|
+
export function findCertifiedIdentityByNativeSessionId(nativeSessionId, dirOverride) {
|
|
1855
|
+
const dir = path.resolve(expandTilde(dirOverride ?? defaultMetaSessionsDir()));
|
|
1856
|
+
if (!fs.existsSync(dir))
|
|
1857
|
+
return null;
|
|
1858
|
+
const cert = certifyActiveStoreDir(dir);
|
|
1859
|
+
if (cert.defects.length > 0)
|
|
1860
|
+
throw new MetaRecordError(activeStoreRefusal(cert));
|
|
1861
|
+
const target = requireNonEmptyString(nativeSessionId, "nativeSessionId");
|
|
1862
|
+
return cert.records.find((record) => record.identity.nativeSessionId === target)?.identity ?? null;
|
|
1863
|
+
}
|
|
1784
1864
|
/**
|
|
1785
1865
|
* ATTACH replace primitive: tmp-file + rename so a crash never leaves a
|
|
1786
1866
|
* half-written record (#30 crash-safety). rename(2) REPLACES an existing final
|
|
@@ -91,8 +91,8 @@ import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
|
|
|
91
91
|
import { CODEX_CALLER_SEAT_HINT, resolveCodexCallerSeat, } from "./codex-caller-seat.js";
|
|
92
92
|
import { CODEX_CALLER_PREFLIGHT_HINT, CODEX_LAUNCH_CWD_PREFLIGHT_HINT, CODEX_PREFLIGHT_HINT, codexLaunchCwdFreshPreflight, } from "./codex-fresh-preflight.js";
|
|
93
93
|
import { COPILOT_PREFLIGHT_HINT, copilotFreshPreflight, } from "./copilot-fresh-preflight.js";
|
|
94
|
-
import { buildOmpBootstrapPayload, composeBackendArgs, composeFreshCallPrompt, FRESH_CALL_BACKENDS, FRESH_CALL_CALLBACK_TOOL, FRESH_CALL_DELIVERY_TOOL, isSafeFreshCallModel, MODEL_MAX_CHARS, mintNonce, normalizeFreshCallInputs, OMP_BOOTSTRAP_FLAG, OMP_BOOTSTRAP_VERSION, TASK_MAX_CHARS, } from "./fresh-call-composition.js";
|
|
95
|
-
import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
|
|
94
|
+
import { buildOmpBootstrapPayload, composeBackendArgs, composeFreshCallPrompt, FRESH_CALL_BACKENDS, FRESH_CALL_CALLBACK_TOOL, FRESH_CALL_DELIVERY_TOOL, FRESH_CALL_PROVIDER_HINT, isSafeFreshCallModel, MODEL_MAX_CHARS, mintNonce, normalizeFreshCallInputs, OMP_BOOTSTRAP_FLAG, OMP_BOOTSTRAP_VERSION, TASK_MAX_CHARS, } from "./fresh-call-composition.js";
|
|
95
|
+
import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, SCRUBBED_INHERITED_ENV, } from "./mux-launch.js";
|
|
96
96
|
import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
|
|
97
97
|
import { resolveCodexDefaultSocketPath } from "./native-push/codex-ws-client.js";
|
|
98
98
|
import { OMP_PREFLIGHT_HINT, ompFreshPreflight } from "./omp-fresh-preflight.js";
|
|
@@ -188,33 +188,8 @@ export function selectFreshCallSeat(placement) {
|
|
|
188
188
|
return { tmuxSession: placement.tmuxSession, source: "requested" };
|
|
189
189
|
return null;
|
|
190
190
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
*
|
|
194
|
-
* `[측정]` 2026-08-30, private tmux server: a `new-window` pane inherits the tmux SERVER's
|
|
195
|
-
* environment, not the caller's. A server started from a shell that exported `PI_SESSION_ID`
|
|
196
|
-
* hands that value to every window it will ever open — the control run printed
|
|
197
|
-
* `SID=[leaked-uuid]` in a pane the caller never touched. Nothing about the fresh call creates
|
|
198
|
-
* that leak and nothing about it notices: the sibling's own MCP child would read the STALE pair
|
|
199
|
-
* as its authoritative identity and call home as a citizen it is not (`mcp/entwurf-bridge/
|
|
200
|
-
* src/index.ts:692-698` keeps the measured incident — a fresh cell answering with the uuidv7 it
|
|
201
|
-
* found in the environment, confidently and wrong).
|
|
202
|
-
*
|
|
203
|
-
* `-e VAR=` sets the variable EMPTY rather than unsetting it, which tmux has no per-window form
|
|
204
|
-
* for. That is sufficient and not a compromise: every reader of the carrier trims and tests
|
|
205
|
-
* truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
|
|
206
|
-
*
|
|
207
|
-
* It is applied to all six backends because the leak is a property of tmux, not of a vendor. A
|
|
208
|
-
* scrub only on the backend whose measurement surfaced it would encode the claim that the other
|
|
209
|
-
* five are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
|
|
210
|
-
* authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
|
|
211
|
-
* exactly that after this argv has run. This is a fixed seam and deliberately NOT a general env
|
|
212
|
-
* carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping power this
|
|
213
|
-
* rail exists to refuse. Two more `-e` assignments ride beside the scrub: the callback pair
|
|
214
|
-
* (`callback-env.ts`), launcher-computed, so the sibling's first action does not retype an
|
|
215
|
-
* address out of prose.
|
|
216
|
-
*/
|
|
217
|
-
const SCRUBBED_INHERITED_ENV = ["PI_SESSION_ID=", "PI_AGENT_ID="];
|
|
191
|
+
// The identity scrub every fresh window carries is `SCRUBBED_INHERITED_ENV` (mux-launch.ts), shared
|
|
192
|
+
// with the resume rail because the leak it closes is a property of tmux, not of either verb.
|
|
218
193
|
/** Launch argv: the leaf's detached-append shape, the identity scrub, optionally `-c` at the
|
|
219
194
|
* resume-symmetric token position (after `-t`, before `-P -F`), the runtime, then the backend's
|
|
220
195
|
* dialect. An omitted cwd adds no `-c` carrier at all.
|
|
@@ -478,6 +453,7 @@ const REJECT_HINT = {
|
|
|
478
453
|
...CODEX_PREFLIGHT_HINT,
|
|
479
454
|
...CODEX_CALLER_PREFLIGHT_HINT,
|
|
480
455
|
...CODEX_CALLER_SEAT_HINT,
|
|
456
|
+
...FRESH_CALL_PROVIDER_HINT,
|
|
481
457
|
"no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
|
|
482
458
|
"anchor-malformed": "TMUX_PANE is not a native pane id",
|
|
483
459
|
"anchor-unresolved": "tmux resolved no pane for this agent's anchor",
|
|
@@ -59,6 +59,34 @@ import { APPEND_FORMAT, assertSelector, assertTmuxOk, parseWindowFields, require
|
|
|
59
59
|
/** The official runtime this rail opens. FIXED: there is no command carrier, and adding one
|
|
60
60
|
* is a separate ownership decision (docs §11), not a parameter. */
|
|
61
61
|
export const PI_RUNTIME_COMMAND = "pi";
|
|
62
|
+
/**
|
|
63
|
+
* The pi identity carrier, scrubbed at the launch seam for EVERY backend (#87 Bundle C).
|
|
64
|
+
*
|
|
65
|
+
* `[측정]` 2026-08-30, private tmux server: a `new-window` pane inherits the tmux SERVER's
|
|
66
|
+
* environment, not the caller's. A server started from a shell that exported `PI_SESSION_ID`
|
|
67
|
+
* hands that value to every window it will ever open — the control run printed
|
|
68
|
+
* `SID=[leaked-uuid]` in a pane the caller never touched. Nothing about the fresh call creates
|
|
69
|
+
* that leak and nothing about it notices: the sibling's own MCP child would read the STALE pair
|
|
70
|
+
* as its authoritative identity and call home as a citizen it is not (`mcp/entwurf-bridge/
|
|
71
|
+
* src/index.ts:692-698` keeps the measured incident — a fresh cell answering with the uuidv7 it
|
|
72
|
+
* found in the environment, confidently and wrong).
|
|
73
|
+
*
|
|
74
|
+
* `-e VAR=` sets the variable EMPTY rather than unsetting it, which tmux has no per-window form
|
|
75
|
+
* for. That is sufficient and not a compromise: every reader of the carrier trims and tests
|
|
76
|
+
* truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
|
|
77
|
+
*
|
|
78
|
+
* It is applied to every fresh backend and to every resumed one (#147 L2) because the leak is a
|
|
79
|
+
* property of tmux, not of a vendor or a verb. A
|
|
80
|
+
* scrub only on the backend whose measurement surfaced it would encode the claim that the other
|
|
81
|
+
* five are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
|
|
82
|
+
* authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
|
|
83
|
+
* exactly that after this argv has run. This is a fixed seam and deliberately NOT a general env
|
|
84
|
+
* carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping power this
|
|
85
|
+
* rail exists to refuse. Two more `-e` assignments ride beside the scrub: the callback pair
|
|
86
|
+
* (`callback-env.ts`), launcher-computed, so the sibling's first action does not retype an
|
|
87
|
+
* address out of prose.
|
|
88
|
+
*/
|
|
89
|
+
export const SCRUBBED_INHERITED_ENV = ["PI_SESSION_ID=", "PI_AGENT_ID="];
|
|
62
90
|
/** A named precondition failure. Callers surface `reason`; they never soften it into a
|
|
63
91
|
* fallback launch of something else. */
|
|
64
92
|
export class LaunchPreconditionError extends Error {
|
|
@@ -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
|
*
|
|
@@ -41,15 +49,18 @@
|
|
|
41
49
|
* to acceptance, not to the product's launch receipt.
|
|
42
50
|
*/
|
|
43
51
|
import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
|
|
44
|
-
import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
|
|
52
|
+
import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, SCRUBBED_INHERITED_ENV, } from "./mux-launch.js";
|
|
45
53
|
import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
|
|
46
|
-
/** The fixed runtime
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
export const RESUME_CALL_RUNTIME = "pi";
|
|
54
|
+
/** The fixed runtime each resumable backend reopens with. A closed table, not a command input: the
|
|
55
|
+
* backend boundary is enforced upstream by `resolveResumeLaunchIdentity`, and this is the mux half
|
|
56
|
+
* of the same fact. */
|
|
57
|
+
export const RESUME_CALL_RUNTIME = { pi: "pi", "claude-code": "claude" };
|
|
50
58
|
/**
|
|
51
|
-
* Launch argv: the leaf's detached-append shape
|
|
52
|
-
*
|
|
59
|
+
* Launch argv: the leaf's detached-append shape, the fixed identity scrub (`SCRUBBED_INHERITED_ENV`,
|
|
60
|
+
* the same two assignments every fresh window carries), `-c`, the runtime, then the caller's flags.
|
|
61
|
+
* `--` is what keeps tmux from reading the runtime or its flags as tmux options. The scrub is not a
|
|
62
|
+
* carrier: it only empties identity a tmux server may have inherited, and a resumed Claude Code
|
|
63
|
+
* (which, unlike pi, never re-exports its own) would otherwise present a stale one to its bridge.
|
|
53
64
|
*/
|
|
54
65
|
export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
|
|
55
66
|
assertSelector("session", placement.sessionId);
|
|
@@ -61,6 +72,7 @@ export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
|
|
|
61
72
|
"new-window",
|
|
62
73
|
"-d",
|
|
63
74
|
"-a",
|
|
75
|
+
...SCRUBBED_INHERITED_ENV.flatMap((assignment) => ["-e", assignment]),
|
|
64
76
|
"-t",
|
|
65
77
|
`${placement.sessionId}:{end}`,
|
|
66
78
|
"-c",
|
|
@@ -81,12 +93,14 @@ export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
|
|
|
81
93
|
* `runtimeArgs` is passed through untouched. This module does not know what `--session` means.
|
|
82
94
|
*/
|
|
83
95
|
export function resumeCall(params, env = process.env) {
|
|
96
|
+
if (env.HERDR_ENV === "1")
|
|
97
|
+
return { ok: false, reason: "herdr-resume-unsupported" };
|
|
84
98
|
const badCwd = classifyTmuxCwd(params.cwd);
|
|
85
99
|
if (badCwd)
|
|
86
100
|
return { ok: false, reason: badCwd };
|
|
87
101
|
let runtimePath;
|
|
88
102
|
try {
|
|
89
|
-
runtimePath = resolveRuntimeOnPath(RESUME_CALL_RUNTIME, env);
|
|
103
|
+
runtimePath = resolveRuntimeOnPath(RESUME_CALL_RUNTIME[params.backend], env);
|
|
90
104
|
}
|
|
91
105
|
catch (err) {
|
|
92
106
|
if (err instanceof LaunchPreconditionError)
|
|
@@ -132,7 +146,8 @@ export const RESUME_CALL_REJECT_HINT = {
|
|
|
132
146
|
"cwd-format-token": "the recorded cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
|
|
133
147
|
"cwd-missing": "the recorded cwd no longer exists; tmux would not report this, it would open the window in $HOME and look successful",
|
|
134
148
|
"cwd-not-directory": "the recorded cwd exists but is not a directory",
|
|
135
|
-
"
|
|
149
|
+
"herdr-resume-unsupported": "this agent runs inside herdr, and same-id resume has only a tmux rail — nothing was opened in any ambient tmux session",
|
|
150
|
+
"runtime-unresolved": "the citizen's runtime (pi or claude) is not installed on PATH",
|
|
136
151
|
"runtime-not-absolute": "the resolved runtime path is not absolute",
|
|
137
152
|
"runtime-path-whitespace": "the resolved runtime path contains whitespace, which tmux would re-split",
|
|
138
153
|
"runtime-missing": "nothing exists at the resolved runtime path",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Pi core-side request adapter, not a public tool or authorization boundary.
|
|
2
|
+
* The caller supplies a CERTIFIED MetaIdentity and owns authorization, self-retire refusal,
|
|
3
|
+
* target locking and record freshness. No second lock, record scan or receiver admission
|
|
4
|
+
* implementation lives here. The receiver owns generation/TUI/idle/pending validation.
|
|
5
|
+
*/
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import { controlSocketPathIn, defaultControlSocketDir } from "./control-socket-path.js";
|
|
8
|
+
import { sendRpcCommand } from "./entwurf-control-rpc.js";
|
|
9
|
+
import { SESSION_ID_RE } from "./session-id.js";
|
|
10
|
+
const RECEIVER_REFUSALS = [
|
|
11
|
+
"target-closing",
|
|
12
|
+
"target-stale",
|
|
13
|
+
"target-context-unavailable",
|
|
14
|
+
"target-non-tui",
|
|
15
|
+
"target-busy",
|
|
16
|
+
"target-pending",
|
|
17
|
+
];
|
|
18
|
+
function object(value) {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function exactKeys(value, keys) {
|
|
22
|
+
const actual = Object.keys(value);
|
|
23
|
+
return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
24
|
+
}
|
|
25
|
+
/** Closed wire combinations only. A malformed answer AFTER sending cannot prove no action.
|
|
26
|
+
* SDK request acceptance is not exit, persistence/flush, or no-further-model-activity evidence.
|
|
27
|
+
*/
|
|
28
|
+
export function parsePiRetireResponse(response) {
|
|
29
|
+
const malformed = { boundary: "unknown", reason: "malformed-response" };
|
|
30
|
+
if (!object(response) || response.type !== "response" || response.command !== "retire")
|
|
31
|
+
return malformed;
|
|
32
|
+
if (Object.keys(response).some((key) => !["type", "command", "success", "data", "error", "id"].includes(key))) {
|
|
33
|
+
return malformed;
|
|
34
|
+
}
|
|
35
|
+
if (Object.hasOwn(response, "id") && typeof response.id !== "string")
|
|
36
|
+
return malformed;
|
|
37
|
+
const data = response.data;
|
|
38
|
+
if (!object(data))
|
|
39
|
+
return malformed;
|
|
40
|
+
if (response.success === true && data.boundary === "shutdown-requested") {
|
|
41
|
+
if (!exactKeys(data, ["boundary"]) || Object.hasOwn(response, "error"))
|
|
42
|
+
return malformed;
|
|
43
|
+
return { boundary: "shutdown-requested" };
|
|
44
|
+
}
|
|
45
|
+
if (response.success !== false || !exactKeys(data, ["boundary", "reason"]) || response.error !== data.reason) {
|
|
46
|
+
return malformed;
|
|
47
|
+
}
|
|
48
|
+
if (data.boundary === "refused" && RECEIVER_REFUSALS.includes(data.reason)) {
|
|
49
|
+
return { boundary: "refused", phase: "receiver", reason: data.reason };
|
|
50
|
+
}
|
|
51
|
+
if (data.boundary === "unknown" && data.reason === "shutdown-call-failed") {
|
|
52
|
+
return { boundary: "unknown", reason: "shutdown-call-failed" };
|
|
53
|
+
}
|
|
54
|
+
return malformed;
|
|
55
|
+
}
|
|
56
|
+
export async function requestPiRetire(target, expected, options = {}) {
|
|
57
|
+
if (target.backend !== "pi") {
|
|
58
|
+
return { boundary: "refused", phase: "preflight", reason: "target-backend-unsupported" };
|
|
59
|
+
}
|
|
60
|
+
if (target.gardenId !== expected.gardenId ||
|
|
61
|
+
target.nativeSessionId !== expected.nativeSessionId ||
|
|
62
|
+
!SESSION_ID_RE.test(target.gardenId) ||
|
|
63
|
+
target.nativeSessionId.length === 0) {
|
|
64
|
+
return { boundary: "refused", phase: "preflight", reason: "target-identity-mismatch" };
|
|
65
|
+
}
|
|
66
|
+
const socketPath = controlSocketPathIn(options.controlSocketDir ?? defaultControlSocketDir(os.homedir()), target.gardenId);
|
|
67
|
+
try {
|
|
68
|
+
const { response } = await (options.sendRpc ?? sendRpcCommand)(socketPath, { type: "retire", gardenId: target.gardenId, nativeSessionId: target.nativeSessionId }, { timeout: options.timeoutMs ?? 5000 });
|
|
69
|
+
return parsePiRetireResponse(response);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// The RPC API does not expose whether bytes were written. Even connection errors
|
|
73
|
+
// are conservatively unknown here; do not infer a safe pre-send refusal or retry.
|
|
74
|
+
return { boundary: "unknown", reason: "transport-error" };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -1,27 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* resume-launch-identity — the RECORD-AUTHORITATIVE half of
|
|
3
|
-
*
|
|
2
|
+
* resume-launch-identity — the RECORD-AUTHORITATIVE half of a visible same-id resume: garden id →
|
|
3
|
+
* the meta-record → which native session is this citizen's, and where it lives.
|
|
4
4
|
*
|
|
5
|
-
* It
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* It began as the preamble of the retired `spawn-bg` watcher (garden id → the meta-record → the
|
|
6
|
+
* transcript that record claims → the provider/model/cwd that transcript's own header carries).
|
|
7
|
+
* None of that is about being hidden or visible; it is the answer to "which being is this, and
|
|
8
|
+
* which conversation is theirs". `entwurf-v2-visible-resume` is its consumer, and
|
|
9
|
+
* `check-resume-launch-identity` holds the record-integrity rules below — the transcript header id
|
|
10
|
+
* must equal `record.nativeSessionId`, an addressable read (#52) rather than a plain targeted one,
|
|
11
|
+
* a recorded ACP provider must resolve its bridge.
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* the transcript header
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* The domain is pi and claude-code (#147 L2). Both own their transcript and re-attach to the same
|
|
14
|
+
* record when their native session reopens; this leaf only names that session. For pi it reads
|
|
15
|
+
* the transcript header (the launch needs its provider/model/cwd). For claude-code it reads no
|
|
16
|
+
* transcript at all — the vendor owns it, and `claude --resume <id>` needs nothing from it — and
|
|
17
|
+
* checks only that the recorded path is an absolute file that still exists. Every other backend
|
|
18
|
+
* is a named refusal: native-push citizens have no resume authority (AGENTS Hard Rule 10), and a
|
|
19
|
+
* durable host is not reopened through Entwurf.
|
|
16
20
|
*
|
|
17
21
|
* It takes a bare garden id, not a plan: the plan type it used to destructure belonged to
|
|
18
22
|
* the removed transport, and the identity question never needed the rest of it.
|
|
19
23
|
*/
|
|
20
|
-
import { existsSync } from "node:fs";
|
|
24
|
+
import { existsSync, statSync } from "node:fs";
|
|
21
25
|
import path from "node:path";
|
|
22
26
|
import { AcpModelNotCuratedError, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
|
|
23
27
|
import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.js";
|
|
24
28
|
import { readAddressableMetaIdentity } from "./meta-session.js";
|
|
29
|
+
/** The backends with a visible same-id resume. A closed set: nothing outside it is reopened. */
|
|
30
|
+
export const RESUME_BACKENDS = ["pi", "claude-code"];
|
|
25
31
|
/**
|
|
26
32
|
* One of the TWO expected refusals on this leaf: the target is a citizen of a backend that has no
|
|
27
33
|
* same-id resume at all. The other is `AcpModelNotCuratedError` (#128, `acp/model-ids.js`).
|
|
@@ -31,23 +37,64 @@ import { readAddressableMetaIdentity } from "./meta-session.js";
|
|
|
31
37
|
* bridge — and those keep their bare cause-rich `Error`, because a caller cannot act on them
|
|
32
38
|
* except by looking. A Claude Code or agy garden id is different in kind: nothing is broken,
|
|
33
39
|
* the operator simply asked a capability boundary to do something it does not cover, and the
|
|
34
|
-
* honest answer is a named refusal rather than an error report about a store that is fine.
|
|
40
|
+
* honest answer is a named refusal rather than an error report about a store that is fine. Since
|
|
41
|
+
* #147 L2 the boundary is `RESUME_BACKENDS`; a Codex, agy, Copilot, OMP or pi-durable garden id is
|
|
42
|
+
* this refusal.
|
|
35
43
|
*
|
|
36
44
|
* It carries a `reason` so the caller matches on a FIELD. Parsing the message string would make
|
|
37
45
|
* the wording load-bearing, and re-reading the record to ask "was it pi?" would read an address
|
|
38
46
|
* twice and could answer differently the second time.
|
|
39
47
|
*/
|
|
40
48
|
export class ResumeBackendUnsupportedError extends Error {
|
|
41
|
-
reason = "target-
|
|
49
|
+
reason = "target-backend-unsupported";
|
|
42
50
|
backend;
|
|
43
51
|
constructor(gardenId, backend) {
|
|
44
52
|
super(`resume-launch-identity: ${gardenId} is a ${backend} citizen — same-id resume is a host-adapter relaunch ` +
|
|
45
|
-
`capability, and its domain
|
|
46
|
-
`This is a capability boundary, not
|
|
53
|
+
`capability, and its domain is ${RESUME_BACKENDS.join(" and ")}. ` +
|
|
54
|
+
`This is a capability boundary, not citizen rank.`);
|
|
47
55
|
this.name = "ResumeBackendUnsupportedError";
|
|
48
56
|
this.backend = backend;
|
|
49
57
|
}
|
|
50
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Claude Code's session id grammar: a lowercase UUID. The value becomes an argv token after
|
|
61
|
+
* `--resume`, so anything else — a leading `-`, whitespace, a path — is refused as a bad record
|
|
62
|
+
* rather than handed to the vendor CLI as something it would parse differently.
|
|
63
|
+
*/
|
|
64
|
+
const CLAUDE_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
65
|
+
/** A claude-code record → its resume facts. Throws, with its own cause, on a record that cannot be resumed. */
|
|
66
|
+
function resolveClaudeCodeLaunchIdentity(gardenId, record) {
|
|
67
|
+
if (!CLAUDE_SESSION_ID.test(record.nativeSessionId)) {
|
|
68
|
+
throw new Error(`resume-launch-identity: ${gardenId} records nativeSessionId ${JSON.stringify(record.nativeSessionId)}, ` +
|
|
69
|
+
`which is not a Claude Code session id (lowercase UUID) — refusing to hand it to claude --resume.`);
|
|
70
|
+
}
|
|
71
|
+
const transcript = record.transcriptPath;
|
|
72
|
+
if (!transcript || !path.isAbsolute(transcript)) {
|
|
73
|
+
throw new Error(`resume-launch-identity: ${gardenId} records no absolute transcriptPath (${JSON.stringify(transcript)}) — ` +
|
|
74
|
+
`the birth hook writes one for every Claude Code session, so this record cannot name a session to resume.`);
|
|
75
|
+
}
|
|
76
|
+
let isFile = false;
|
|
77
|
+
try {
|
|
78
|
+
isFile = statSync(transcript).isFile();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
isFile = false;
|
|
82
|
+
}
|
|
83
|
+
if (!isFile) {
|
|
84
|
+
throw new Error(`resume-launch-identity: ${gardenId} recorded transcriptPath "${transcript}" is not a file on disk — ` +
|
|
85
|
+
`the session's transcript is gone, so claude --resume would open nothing.`);
|
|
86
|
+
}
|
|
87
|
+
if (!path.isAbsolute(record.cwd)) {
|
|
88
|
+
throw new Error(`resume-launch-identity: ${gardenId} records a relative cwd ${JSON.stringify(record.cwd)}.`);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
backend: "claude-code",
|
|
92
|
+
nativeSessionId: record.nativeSessionId,
|
|
93
|
+
cwd: record.cwd,
|
|
94
|
+
createdAt: record.createdAt,
|
|
95
|
+
attachIncarnation: record.attachIncarnation,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
51
98
|
/**
|
|
52
99
|
* Resolve launch identity for a resume. The TARGET is now resolved through the
|
|
53
100
|
* meta-record (#50 C2): `gardenId → record.transcriptPath`. It used to be a global
|
|
@@ -79,6 +126,8 @@ export class ResumeBackendUnsupportedError extends Error {
|
|
|
79
126
|
*/
|
|
80
127
|
export function resolveResumeLaunchIdentity(gardenId) {
|
|
81
128
|
const record = readAddressableMetaIdentity(gardenId);
|
|
129
|
+
if (record.backend === "claude-code")
|
|
130
|
+
return resolveClaudeCodeLaunchIdentity(gardenId, record);
|
|
82
131
|
if (record.backend !== "pi") {
|
|
83
132
|
throw new ResumeBackendUnsupportedError(gardenId, record.backend);
|
|
84
133
|
}
|
|
@@ -136,6 +185,7 @@ export function resolveResumeLaunchIdentity(gardenId) {
|
|
|
136
185
|
if (provider === PROVIDER_ID && !isCuratedAcpModelId(model))
|
|
137
186
|
throw new AcpModelNotCuratedError(model);
|
|
138
187
|
return {
|
|
188
|
+
backend: "pi",
|
|
139
189
|
sessionFile,
|
|
140
190
|
cwd: identity.cwd,
|
|
141
191
|
explicitExtensionArgs: explicitExtensions.args,
|