@junghanacs/entwurf 0.13.0 → 0.14.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 +52 -17
- package/BASELINE.md +60 -206
- package/CHANGELOG.md +64 -0
- package/CONTRIBUTING.md +14 -10
- package/DELIVERY.md +118 -261
- package/README.md +91 -431
- package/VERIFY.md +74 -95
- package/demo/README.md +1 -1
- package/demo/demo-baseline.sh +1 -3
- package/demo/demo.sh +2 -5
- package/docs/acp-backend-rail.md +236 -0
- package/docs/external-mcp-host.md +131 -0
- package/docs/fresh-cut-policy.md +99 -0
- package/docs/setup-clean-host.md +124 -328
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
- package/mcp/entwurf-bridge/src/index.ts +176 -67
- package/mcp/entwurf-bridge/start.sh +2 -2
- package/mcp/entwurf-bridge/test.sh +23 -9
- package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
- package/package.json +25 -11
- package/pi-extensions/entwurf-control.ts +220 -71
- package/pi-extensions/lib/acp/acp-client.ts +22 -0
- package/pi-extensions/lib/acp/augment.ts +42 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
- package/pi-extensions/lib/acp/backend.ts +366 -38
- package/pi-extensions/lib/acp/config.ts +1 -1
- package/pi-extensions/lib/acp/engraving.ts +43 -1
- package/pi-extensions/lib/acp/event-mapper.ts +16 -13
- package/pi-extensions/lib/acp/models.ts +4 -4
- package/pi-extensions/lib/acp/overlay.ts +11 -9
- package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
- package/pi-extensions/lib/entwurf-core.ts +15 -15
- package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
- package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
- package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
- package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
- package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
- package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
- package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
- package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
- package/pi-extensions/lib/meta-session.ts +93 -5
- package/pi-extensions/lib/mux-fresh-call.ts +328 -0
- package/pi-extensions/lib/mux-launch.ts +267 -0
- package/pi-extensions/lib/mux-placement.ts +387 -0
- package/pi-extensions/lib/mux-resume-call.ts +221 -0
- package/pi-extensions/lib/resume-launch-identity.ts +162 -0
- package/pi-extensions/lib/session-id.js +8 -5
- package/pi-extensions/lib/socket-discovery.ts +3 -3
- package/prompts/engraving.md +13 -5
- package/run.sh +688 -282
- package/scripts/agy-bridge-config.py +5 -1
- package/scripts/check-acp-backend-preflight.ts +1 -1
- package/scripts/check-acp-carrier-augment.ts +246 -10
- package/scripts/check-acp-cortex.ts +5 -5
- package/scripts/check-acp-overlay.ts +13 -3
- package/scripts/check-acp-prompt-lifecycle.ts +565 -0
- package/scripts/check-acp-stop-reason.ts +342 -0
- package/scripts/check-acp-stream-hooks.ts +504 -0
- package/scripts/check-elapsed.sh +25 -0
- package/scripts/check-entwurf-bridge-boot.ts +51 -4
- package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
- package/scripts/check-entwurf-control-rpc.ts +4 -3
- package/scripts/check-entwurf-resume-args.ts +72 -70
- package/scripts/check-entwurf-session-identity.ts +14 -10
- package/scripts/check-entwurf-v2-contract.ts +34 -59
- package/scripts/check-entwurf-v2-decider.ts +17 -177
- package/scripts/check-entwurf-v2-lock.ts +5 -2
- package/scripts/check-entwurf-v2-matrix.ts +3 -53
- package/scripts/check-entwurf-v2-production.ts +2 -91
- package/scripts/check-entwurf-v2-release.ts +10 -105
- package/scripts/check-entwurf-v2-runner.ts +4 -85
- package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
- package/scripts/check-entwurf-v2-send.ts +0 -28
- package/scripts/check-entwurf-v2-surface.ts +198 -128
- package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
- package/scripts/check-fresh-cut-gate.sh +1 -1
- package/scripts/check-gate-qualification.ts +101 -7
- package/scripts/check-install-container.sh +10 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-keyset-overlap.py +1 -1
- package/scripts/check-meta-facts.ts +249 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-meta-session.ts +169 -0
- package/scripts/check-mux-launch-tmux.ts +316 -0
- package/scripts/check-mux-launch.ts +288 -0
- package/scripts/check-mux-launcher-fence.ts +264 -0
- package/scripts/check-mux-parent-artifact.ts +195 -0
- package/scripts/check-mux-placement-tmux.ts +322 -0
- package/scripts/check-mux-placement.ts +323 -0
- package/scripts/check-mux-resume-call.ts +282 -0
- package/scripts/check-probe-cli-shim.ts +25 -22
- package/scripts/check-probe-ordering.ts +121 -81
- package/scripts/check-release-gate-outcomes.ts +452 -0
- package/scripts/check-resume-launch-identity.ts +244 -0
- package/scripts/check-socket-discovery.ts +1 -1
- package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
- package/scripts/inventory-verification-surface.ts +349 -0
- package/scripts/lib/claude-launcher-fence.ts +322 -0
- package/scripts/lib/live-skip.ts +33 -0
- package/scripts/lib/mutation-qualify.ts +109 -3
- package/scripts/lib/probe-acp-turn.ts +33 -12
- package/scripts/lib/step-outcome.sh +88 -0
- package/scripts/meta-bridge-doctor.sh +6 -8
- package/scripts/meta-facts.ts +60 -0
- package/scripts/mutants/acp-augment.json +106 -0
- package/scripts/mutants/acp-cortex.json +2 -2
- package/scripts/mutants/acp-overlay.json +17 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
- package/scripts/mutants/acp-stop-reason.json +80 -0
- package/scripts/mutants/acp-stream-hooks.json +158 -0
- package/scripts/mutants/bridge-boot-resume.json +45 -0
- package/scripts/mutants/meta-facts.json +50 -0
- package/scripts/mutants/meta-identity.json +36 -0
- package/scripts/mutants/mux-boundary.json +196 -0
- package/scripts/mutants/mux-fresh-call.json +185 -0
- package/scripts/mutants/mux-launcher-fence.json +123 -0
- package/scripts/mutants/mux-parent-artifact.json +39 -0
- package/scripts/mutants/mux-resume-call.json +148 -0
- package/scripts/mutants/probe-ordering.json +6 -1021
- package/scripts/mutants/release-gate.json +140 -0
- package/scripts/mutants/resume-args.json +76 -0
- package/scripts/mutants/resume-launch-identity.json +96 -0
- package/scripts/mutants/v2-surface.json +74 -23
- package/scripts/mutants/v2-visible-resume.json +215 -0
- package/scripts/new-session-id.ts +9 -4
- package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
- package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +8 -14
- package/scripts/smoke-acp-long-turn-live.ts +185 -0
- package/scripts/smoke-acp-mcp-live.ts +2 -2
- package/scripts/smoke-acp-memory-containment-live.ts +2 -2
- package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
- package/scripts/smoke-acp-overlay-live.ts +2 -2
- package/scripts/smoke-acp-provider-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +3 -3
- package/scripts/smoke-acp-session-reuse-live.ts +2 -2
- package/scripts/smoke-acp-skill-live.ts +2 -2
- package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
- package/scripts/smoke-acp-v2-send-live.ts +4 -3
- package/scripts/smoke-agy-native-push-live.ts +10 -20
- package/scripts/smoke-claude-native-resume-live.sh +13 -3
- package/scripts/smoke-entwurf-chain-live.ts +352 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
- package/scripts/smoke-meta-honesty.sh +1 -1
- package/scripts/smoke-meta-install-state.sh +4 -0
- package/scripts/smoke-mux-fresh-call-live.ts +365 -0
- package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
- package/scripts/smoke-pi-attach.ts +1 -1
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
- package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
- package/scripts/check-acp-sdk-surface.ts +0 -236
- package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
- package/scripts/check-entwurf-v2-spawn.ts +0 -399
- package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
|
@@ -514,7 +514,7 @@ export function parseMetaIdentity(json: string): MetaIdentity {
|
|
|
514
514
|
// `wakeMode` describes the last centimetre, not citizen rank. The control-socket
|
|
515
515
|
// adapter injects the body into a pi turn, so backend `pi` is `direct-inject`;
|
|
516
516
|
// Claude's mailbox is `self-fetch`; native-push adapters are direct injection.
|
|
517
|
-
//
|
|
517
|
+
// Resuming a dormant citizen is a separate lifecycle capability and does not
|
|
518
518
|
// turn the pi record into a mailbox citizen.
|
|
519
519
|
// ---------------------------------------------------------------------------
|
|
520
520
|
|
|
@@ -1090,6 +1090,22 @@ export function listAllMetaIdentities(
|
|
|
1090
1090
|
return { identities, errors };
|
|
1091
1091
|
}
|
|
1092
1092
|
|
|
1093
|
+
/**
|
|
1094
|
+
* The fs-bound listing — the same binding shape {@link certifyActiveStoreDir} gives the
|
|
1095
|
+
* certification: one resolved dir, kind-carrying entries, the fd-guarded record reader.
|
|
1096
|
+
* Exists so an in-repo facts surface (`meta-facts`, #65) reuses THE listing contract
|
|
1097
|
+
* instead of re-binding readdir/read by hand — the exact drift that made external
|
|
1098
|
+
* consumers carry a decaying copy of the certification. No semantics of its own:
|
|
1099
|
+
* everything it returns is {@link listAllMetaIdentities} over the real store.
|
|
1100
|
+
*/
|
|
1101
|
+
export function listAllMetaIdentitiesDir(dir: string): ListIdentitiesResult & { dir: string } {
|
|
1102
|
+
const resolved = path.resolve(expandTilde(dir));
|
|
1103
|
+
return {
|
|
1104
|
+
dir: resolved,
|
|
1105
|
+
...listAllMetaIdentities(readActiveStoreEntries(resolved), makeStoreRecordReader(resolved)),
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1093
1109
|
export type UpsertAction = "create" | "attach";
|
|
1094
1110
|
|
|
1095
1111
|
export interface UpsertDecision {
|
|
@@ -1747,8 +1763,13 @@ export interface UpsertMetaSessionResult {
|
|
|
1747
1763
|
* A narrower scan is not a smaller version of this: asking only about MY
|
|
1748
1764
|
* `nativeSessionId` writes happily beside a drifted or duplicated record that the
|
|
1749
1765
|
* doctor refuses, which is how a host ends up certified by one surface and not
|
|
1750
|
-
* the other.
|
|
1751
|
-
*
|
|
1766
|
+
* the other.
|
|
1767
|
+
*
|
|
1768
|
+
* The write is split by ACTION (#66): ATTACH rewrites its OWN record atomically
|
|
1769
|
+
* in place (tmp + rename, #30 crash-safety); CREATE publishes exclusively
|
|
1770
|
+
* (tmp + link) and fails loud if the minted garden id's final path already
|
|
1771
|
+
* exists — certification cannot see a future minted gid, so replacement-free
|
|
1772
|
+
* publish is the only collision safety a create has.
|
|
1752
1773
|
*/
|
|
1753
1774
|
export function upsertMetaSession(opts: UpsertMetaSessionOptions): UpsertMetaSessionResult {
|
|
1754
1775
|
const dir = path.resolve(expandTilde(opts.dir ?? defaultMetaSessionsDir()));
|
|
@@ -1759,17 +1780,84 @@ export function upsertMetaSession(opts: UpsertMetaSessionOptions): UpsertMetaSes
|
|
|
1759
1780
|
const existing = cert.records.find((record) => record.identity.nativeSessionId === target)?.identity ?? null;
|
|
1760
1781
|
const decision = decideUpsert(existing, opts.input, opts.now);
|
|
1761
1782
|
const file = path.join(dir, metaRecordFilename(decision.record));
|
|
1762
|
-
|
|
1783
|
+
if (decision.action === "create") publishExclusiveIdentity(file, decision.record);
|
|
1784
|
+
else atomicWriteIdentity(file, decision.record);
|
|
1763
1785
|
return { action: decision.action, record: decision.record, dir, path: file };
|
|
1764
1786
|
}
|
|
1765
1787
|
|
|
1766
|
-
/**
|
|
1788
|
+
/**
|
|
1789
|
+
* ATTACH replace primitive: tmp-file + rename so a crash never leaves a
|
|
1790
|
+
* half-written record (#30 crash-safety). rename(2) REPLACES an existing final
|
|
1791
|
+
* path, which is exactly right for attach (the record being rewritten is the
|
|
1792
|
+
* caller's own) and exactly wrong for create — a freshly minted garden id must
|
|
1793
|
+
* never land on this path (#66).
|
|
1794
|
+
*/
|
|
1767
1795
|
function atomicWriteIdentity(file: string, identity: MetaIdentity): void {
|
|
1768
1796
|
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
1769
1797
|
fs.writeFileSync(tmp, serializeMetaIdentity(identity), { mode: 0o600 });
|
|
1770
1798
|
fs.renameSync(tmp, file);
|
|
1771
1799
|
}
|
|
1772
1800
|
|
|
1801
|
+
/**
|
|
1802
|
+
* CREATE publish primitive: same-directory tmp + `link(2)` to the final path.
|
|
1803
|
+
* link fails with EEXIST when the final path already exists — the kernel makes
|
|
1804
|
+
* the existence check and the publish one atomic step, so there is no
|
|
1805
|
+
* check-then-rename TOCTOU window and a same-second 24-bit garden-id suffix
|
|
1806
|
+
* collision can never silently replace whatever already holds the final path
|
|
1807
|
+
* (#66). The occupied entry's bytes are untouched on refusal; tmp cleanup is
|
|
1808
|
+
* attempted on every path, and a published-with-residue state fails loud.
|
|
1809
|
+
*
|
|
1810
|
+
* @internal Exported only as the production subject for the meta-session gate's
|
|
1811
|
+
* deterministic collision oracle (the minted suffix is random, so the public
|
|
1812
|
+
* upsert seam cannot force a collision deterministically). Not an
|
|
1813
|
+
* operator/package API.
|
|
1814
|
+
*/
|
|
1815
|
+
export function publishExclusiveIdentity(file: string, identity: MetaIdentity): void {
|
|
1816
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
1817
|
+
fs.writeFileSync(tmp, serializeMetaIdentity(identity), { mode: 0o600 });
|
|
1818
|
+
try {
|
|
1819
|
+
fs.linkSync(tmp, file);
|
|
1820
|
+
} catch (err) {
|
|
1821
|
+
try {
|
|
1822
|
+
fs.unlinkSync(tmp);
|
|
1823
|
+
} catch (cleanupErr) {
|
|
1824
|
+
// The primary link error stays the truth of this publish; a cleanup errno
|
|
1825
|
+
// never masks it. ENOENT is silent (no residue = desired state); any other
|
|
1826
|
+
// cleanup failure is an operator diagnostic on stderr, then the primary throws.
|
|
1827
|
+
if ((cleanupErr as { code?: unknown }).code !== "ENOENT") {
|
|
1828
|
+
process.stderr.write(
|
|
1829
|
+
`[meta-session] tmp cleanup after a failed CREATE publish also failed (${tmp}): ` +
|
|
1830
|
+
`${(cleanupErr as Error).message}\n`,
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
if ((err as { code?: unknown }).code === "EEXIST") {
|
|
1835
|
+
throw new MetaRecordError(
|
|
1836
|
+
`meta-record CREATE collision: garden id "${identity.gardenId}" already has an entry at ` +
|
|
1837
|
+
`${file}. Exclusive publish refused to replace it — the occupied final-path entry was ` +
|
|
1838
|
+
`not modified and no ACTIVE record was published for nativeSessionId ` +
|
|
1839
|
+
`"${identity.nativeSessionId}".`,
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
throw err;
|
|
1843
|
+
}
|
|
1844
|
+
try {
|
|
1845
|
+
fs.unlinkSync(tmp);
|
|
1846
|
+
} catch (err) {
|
|
1847
|
+
// ENOENT is the goal state, not a failure: the record is published and no
|
|
1848
|
+
// tmp remains, so there is nothing left to clean.
|
|
1849
|
+
if ((err as { code?: unknown }).code === "ENOENT") return;
|
|
1850
|
+
// Never roll the final back: the record IS published (link succeeded).
|
|
1851
|
+
throw new MetaRecordError(
|
|
1852
|
+
`meta-record CREATE for garden id "${identity.gardenId}" is already published at ${file}, ` +
|
|
1853
|
+
`but temp cleanup failed (${tmp}): ${(err as Error).message}. The caller aborts here, so ` +
|
|
1854
|
+
`it received no successful birth/registration receipt; remove the temp file and retry — ` +
|
|
1855
|
+
`a retry with the same nativeSessionId will ATTACH to the published record, not mint a ` +
|
|
1856
|
+
`second identity.`,
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1773
1861
|
// ---------------------------------------------------------------------------
|
|
1774
1862
|
// Mailbox delivery (step 6) — addressed by GARDEN ID. The record store is the
|
|
1775
1863
|
// authority (a sender may only deliver to a known garden citizen); the mailbox
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mux-fresh-call — open ONE visible sibling in the caller's own tmux session, hand it its first
|
|
3
|
+
* task in the launch argv, and let it name itself back to the caller.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this is a third module and not a parameter on the leaf ──
|
|
6
|
+
*
|
|
7
|
+
* `mux-placement` owns place and only place; `mux-launch` owns "start the fixed official pi in
|
|
8
|
+
* a window I placed" and refuses a command carrier (docs §11). Both stay that way. The carrier
|
|
9
|
+
* lives HERE, one layer up, because the thing being carried is not a command in the shell sense
|
|
10
|
+
* — it is a TASK plus the instruction that makes the new citizen announce itself. Putting it on
|
|
11
|
+
* the leaf would make every future placement caller inherit a prompt argument it has no use for.
|
|
12
|
+
*
|
|
13
|
+
* caller agent → fresh-call → fixed backend runtime + first-turn framing
|
|
14
|
+
* fresh-call → placement leaf (unchanged, carrier-free)
|
|
15
|
+
* fresh-call -X-> identity minting, delivery transport, task planning
|
|
16
|
+
*
|
|
17
|
+
* ── Invariants ──
|
|
18
|
+
*
|
|
19
|
+
* 1. The first turn does not MINT the garden id — record and socket exist from session start.
|
|
20
|
+
* It exists to SAY that id to the caller, who is the one who cannot see it. (rail §6-a)
|
|
21
|
+
* 2. Correlation is what the DELIVERY layer stamps on the callback, never what the sibling
|
|
22
|
+
* says about itself — asked directly, a fresh cell answers with a uuidv7. (rail §6-b)
|
|
23
|
+
* 3. The two receipts are separate objects and stay that way: `freshCall` returns tmux
|
|
24
|
+
* coordinates synchronously and nothing else; the correlation receipt arrives later on the
|
|
25
|
+
* caller's own inbound surface. Merging them would claim knowledge this module cannot have.
|
|
26
|
+
* 4. A launch with no callback is a REAL outcome, not an error to retry. No watcher, no poll,
|
|
27
|
+
* no timeout supervisor. The window is visible; the operator can look.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { randomBytes } from "node:crypto";
|
|
31
|
+
import {
|
|
32
|
+
assertLaunchTarget,
|
|
33
|
+
LaunchPreconditionError,
|
|
34
|
+
type LaunchRejectReason,
|
|
35
|
+
resolveRuntimeOnPath,
|
|
36
|
+
} from "./mux-launch.ts";
|
|
37
|
+
import {
|
|
38
|
+
APPEND_FORMAT,
|
|
39
|
+
assertSelector,
|
|
40
|
+
assertTmuxOk,
|
|
41
|
+
inspectPlacement,
|
|
42
|
+
type Placement,
|
|
43
|
+
type PlacementRejectReason,
|
|
44
|
+
parseWindowFields,
|
|
45
|
+
requireSameContext,
|
|
46
|
+
runTmux,
|
|
47
|
+
type WindowHandle,
|
|
48
|
+
} from "./mux-placement.ts";
|
|
49
|
+
|
|
50
|
+
/** The two backends this rail can open. Fixed set, not a profile — a third one is a decision,
|
|
51
|
+
* not a config entry. */
|
|
52
|
+
export const FRESH_CALL_BACKENDS = ["pi", "claude-code"] as const;
|
|
53
|
+
export type FreshCallBackend = (typeof FRESH_CALL_BACKENDS)[number];
|
|
54
|
+
|
|
55
|
+
/** The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
|
|
56
|
+
* than a compiled-in location: the official binary is whatever the operator's environment gives
|
|
57
|
+
* when they type the name. */
|
|
58
|
+
export const FRESH_CALL_RUNTIME: Record<FreshCallBackend, string> = {
|
|
59
|
+
pi: "pi",
|
|
60
|
+
"claude-code": "claude",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
|
|
65
|
+
* capability directly (`entwurf_v2`), while a Claude Code session reaches it through the MCP
|
|
66
|
+
* bridge under its namespaced name. Naming the wrong one costs the whole first turn.
|
|
67
|
+
*/
|
|
68
|
+
export const FRESH_CALL_CALLBACK_TOOL: Record<FreshCallBackend, string> = {
|
|
69
|
+
pi: "entwurf_v2",
|
|
70
|
+
"claude-code": "mcp__entwurf-bridge__entwurf_v2",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
|
|
74
|
+
* delivery surface, not a claim that a task of this size was measured through tmux. An argv
|
|
75
|
+
* that the OS refuses is a launch failure and fails loud — it never reads as a delivered task. */
|
|
76
|
+
export const TASK_MAX_CHARS = 16000;
|
|
77
|
+
export const MODEL_MAX_CHARS = 200;
|
|
78
|
+
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:\[\]-]*$/;
|
|
79
|
+
|
|
80
|
+
/** A model is an explicit launch input, not ambient process state. The grammar admits canonical
|
|
81
|
+
* pi provider/model ids, Claude model ids/aliases, and bracketed context variants, while refusing
|
|
82
|
+
* whitespace and tmux control syntax. It is passed without a shell using each runtime's measured
|
|
83
|
+
* CLI dialect: Pi takes `--model`, value; Claude Code takes `--model=value`. */
|
|
84
|
+
export function isSafeFreshCallModel(model: string): boolean {
|
|
85
|
+
return model.length > 0 && model.length <= MODEL_MAX_CHARS && MODEL_PATTERN.test(model);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Backend argv AFTER the runtime path. Both orders are MEASURED, and both were measured by
|
|
90
|
+
* getting them wrong first (rail §6-a):
|
|
91
|
+
*
|
|
92
|
+
* pi — prompt BEFORE `--entwurf-control`, then `--model`, value as TWO tokens.
|
|
93
|
+
* Flag-first submitted no message; Pi rejects the equals form for `--model`.
|
|
94
|
+
* claude-code — prompt, then `--allowedTools=` and `--model=` as ONE token each. The space form
|
|
95
|
+
* for allowedTools is variadic and eats the prompt as an option value.
|
|
96
|
+
*
|
|
97
|
+
* Both failures looked identical from outside: window open, record and socket minted, no turn.
|
|
98
|
+
*
|
|
99
|
+
* The equals form is NOT a permission guarantee — on the measured host the tool was already
|
|
100
|
+
* permitted, so the option's effect was unobservable. What was observed is that it does no harm
|
|
101
|
+
* to the argv. Permission stays a documented host precondition.
|
|
102
|
+
*/
|
|
103
|
+
export function buildBackendArgs(backend: FreshCallBackend, prompt: string, model: string): string[] {
|
|
104
|
+
switch (backend) {
|
|
105
|
+
case "pi":
|
|
106
|
+
return [prompt, "--entwurf-control", "--model", model];
|
|
107
|
+
case "claude-code":
|
|
108
|
+
return [prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The first-turn framing. Order is the contract: the callback is the FIRST action and the task
|
|
114
|
+
* follows it, so a sibling that gets stuck in the task has already told the caller who it is.
|
|
115
|
+
*
|
|
116
|
+
* The three prohibitions are not politeness. Each names a detour that was measured to produce a
|
|
117
|
+
* confidently wrong answer or a wasted turn.
|
|
118
|
+
*/
|
|
119
|
+
export function buildFreshCallPrompt(params: {
|
|
120
|
+
backend: FreshCallBackend;
|
|
121
|
+
task: string;
|
|
122
|
+
callerGardenId: string;
|
|
123
|
+
nonce: string;
|
|
124
|
+
}): string {
|
|
125
|
+
const tool = FRESH_CALL_CALLBACK_TOOL[params.backend];
|
|
126
|
+
return [
|
|
127
|
+
"You are a fresh visible citizen that entwurf opened in the operator's tmux session.",
|
|
128
|
+
"",
|
|
129
|
+
`FIRST ACTION, before reading files or anything else: call ${tool} with ` +
|
|
130
|
+
`target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
|
|
131
|
+
`message set to exactly ${params.nonce} — that string alone, nothing added.`,
|
|
132
|
+
"That call is how the agent that opened you learns your address. Do not skip it, do not",
|
|
133
|
+
"defer it until the task is done, and do not reword the message.",
|
|
134
|
+
"",
|
|
135
|
+
"Do not inspect environment variables, do not call entwurf_self, and do not start an MCP",
|
|
136
|
+
"server yourself. Your own report of your identity is not the address anyone needs.",
|
|
137
|
+
"",
|
|
138
|
+
"After the tool receipt, carry out this task:",
|
|
139
|
+
"",
|
|
140
|
+
params.task,
|
|
141
|
+
].join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A launch that was refused, or a placement that could not be established. Every value is a
|
|
145
|
+
* NAMED refusal — this module has no fallback launch. */
|
|
146
|
+
export type FreshCallRejectReason =
|
|
147
|
+
| PlacementRejectReason
|
|
148
|
+
| LaunchRejectReason
|
|
149
|
+
| "caller-identity-unavailable"
|
|
150
|
+
| "model-empty"
|
|
151
|
+
| "model-invalid"
|
|
152
|
+
| "task-empty"
|
|
153
|
+
| "task-too-long";
|
|
154
|
+
|
|
155
|
+
/** Coordinates plus what was handed to tmux. Read `runtimePath` as "what we asked to start".
|
|
156
|
+
* There is deliberately NO field here for the callback, the nonce's arrival, or the sibling's
|
|
157
|
+
* garden id — see the module header. */
|
|
158
|
+
export interface FreshCallReceipt extends WindowHandle {
|
|
159
|
+
backend: FreshCallBackend;
|
|
160
|
+
model: string;
|
|
161
|
+
runtimePath: string;
|
|
162
|
+
nonce: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type FreshCallResult = { ok: true; receipt: FreshCallReceipt } | { ok: false; reason: FreshCallRejectReason };
|
|
166
|
+
|
|
167
|
+
/** Correlation tag only. Random, never derived from time, cwd or a peer listing — a nonce that
|
|
168
|
+
* encoded any of those would invite exactly the guessing this rail exists to refuse. */
|
|
169
|
+
export function mintNonce(randomHex: () => string = defaultRandomHex): string {
|
|
170
|
+
return `mux-fresh-call-${randomHex()}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function defaultRandomHex(): string {
|
|
174
|
+
return randomBytes(12).toString("hex");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Launch argv: the leaf's detached-append shape, the runtime, then the backend's dialect. */
|
|
178
|
+
export function buildFreshCallArgs(
|
|
179
|
+
placement: Placement,
|
|
180
|
+
runtimePath: string,
|
|
181
|
+
backendArgs: readonly string[],
|
|
182
|
+
): string[] {
|
|
183
|
+
assertSelector("session", placement.sessionId);
|
|
184
|
+
assertLaunchTarget(runtimePath);
|
|
185
|
+
return [
|
|
186
|
+
"new-window",
|
|
187
|
+
"-d",
|
|
188
|
+
"-a",
|
|
189
|
+
"-t",
|
|
190
|
+
`${placement.sessionId}:{end}`,
|
|
191
|
+
"-P",
|
|
192
|
+
"-F",
|
|
193
|
+
APPEND_FORMAT,
|
|
194
|
+
"--",
|
|
195
|
+
runtimePath,
|
|
196
|
+
...backendArgs,
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Open the sibling. Order is the safety argument, same as `launchPi`: validate the caller's
|
|
202
|
+
* identity and task, resolve and prove the runtime, re-read the caller's placement and refuse a
|
|
203
|
+
* changed context — and only then mutate. Nothing above can leave a window behind.
|
|
204
|
+
*
|
|
205
|
+
* `callerGardenId` is supplied by the SURFACE that registered this tool, from its own
|
|
206
|
+
* record-backed context. It is not a tool parameter and this module never derives, validates
|
|
207
|
+
* against a store, or guesses it: an empty value is a named refusal, not a lookup.
|
|
208
|
+
*/
|
|
209
|
+
export function freshCall(
|
|
210
|
+
params: { backend: FreshCallBackend; model: string; task: string; callerGardenId: string | null },
|
|
211
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
212
|
+
nonce: string = mintNonce(),
|
|
213
|
+
): FreshCallResult {
|
|
214
|
+
if (typeof params.callerGardenId !== "string" || params.callerGardenId.length === 0) {
|
|
215
|
+
return { ok: false, reason: "caller-identity-unavailable" };
|
|
216
|
+
}
|
|
217
|
+
const model = params.model.trim();
|
|
218
|
+
if (model.length === 0) return { ok: false, reason: "model-empty" };
|
|
219
|
+
if (!isSafeFreshCallModel(model)) return { ok: false, reason: "model-invalid" };
|
|
220
|
+
const task = params.task.trim();
|
|
221
|
+
if (task.length === 0) return { ok: false, reason: "task-empty" };
|
|
222
|
+
if (task.length > TASK_MAX_CHARS) return { ok: false, reason: "task-too-long" };
|
|
223
|
+
|
|
224
|
+
let runtimePath: string;
|
|
225
|
+
try {
|
|
226
|
+
runtimePath = resolveRuntimeOnPath(FRESH_CALL_RUNTIME[params.backend], env);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if (err instanceof LaunchPreconditionError) return { ok: false, reason: err.reason };
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const inspected = inspectPlacement(env);
|
|
233
|
+
if (!inspected.ok) return { ok: false, reason: inspected.reason };
|
|
234
|
+
const placement = inspected.placement;
|
|
235
|
+
requireSameContext("freshCall", placement, env);
|
|
236
|
+
|
|
237
|
+
const prompt = buildFreshCallPrompt({
|
|
238
|
+
backend: params.backend,
|
|
239
|
+
task,
|
|
240
|
+
callerGardenId: params.callerGardenId,
|
|
241
|
+
nonce,
|
|
242
|
+
});
|
|
243
|
+
const run = runTmux(buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, prompt, model)), env);
|
|
244
|
+
assertTmuxOk("new-window", run);
|
|
245
|
+
|
|
246
|
+
let fields: ReturnType<typeof parseWindowFields>;
|
|
247
|
+
try {
|
|
248
|
+
fields = parseWindowFields(run.stdout);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
// The window exists and its id is precisely what could not be read. Diffing the inventory
|
|
251
|
+
// to find "the new one" is the guess this rail forbids everywhere else, so name the orphan.
|
|
252
|
+
throw new Error(
|
|
253
|
+
`mux-fresh-call: launched ${runtimePath} but could not read the window handle tmux printed — a window may ` +
|
|
254
|
+
`be open in session ${placement.sessionId} that this call cannot identify or close: ${
|
|
255
|
+
err instanceof Error ? err.message : String(err)
|
|
256
|
+
}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
ok: true,
|
|
262
|
+
receipt: {
|
|
263
|
+
serverPid: placement.serverPid,
|
|
264
|
+
sessionId: placement.sessionId,
|
|
265
|
+
...fields,
|
|
266
|
+
backend: params.backend,
|
|
267
|
+
model,
|
|
268
|
+
runtimePath,
|
|
269
|
+
nonce,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Why each refusal happened, in the caller's terms. A reason a caller cannot act on is a reason
|
|
275
|
+
* they will guess about. */
|
|
276
|
+
const REJECT_HINT: Record<FreshCallRejectReason, string> = {
|
|
277
|
+
"no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
|
|
278
|
+
"anchor-malformed": "TMUX_PANE is not a native pane id",
|
|
279
|
+
"anchor-unresolved": "tmux resolved no pane for this agent's anchor",
|
|
280
|
+
"anchor-mismatch": "tmux answered about a different pane than the one asked about",
|
|
281
|
+
"caller-identity-unavailable":
|
|
282
|
+
"this surface has no record-backed garden id for the caller, so the sibling would have no address to call back to",
|
|
283
|
+
"model-empty": "model is empty after trimming; fresh calls require an explicit model",
|
|
284
|
+
"model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
|
|
285
|
+
"task-empty": "task is empty after trimming",
|
|
286
|
+
"task-too-long": `task exceeds ${TASK_MAX_CHARS} characters`,
|
|
287
|
+
"runtime-unresolved": "the backend's runtime is not installed on PATH",
|
|
288
|
+
"runtime-not-absolute": "the resolved runtime path is not absolute",
|
|
289
|
+
"runtime-path-whitespace": "the resolved runtime path contains whitespace, which tmux would re-split",
|
|
290
|
+
"runtime-missing": "nothing exists at the resolved runtime path",
|
|
291
|
+
"runtime-not-regular-file": "the resolved runtime path is not a regular file",
|
|
292
|
+
"runtime-not-executable": "the resolved runtime path is not executable",
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* ONE renderer for both surfaces. Not a convenience: the two registrations are separate literals
|
|
297
|
+
* (that is this repo's shape), so a shared renderer is what keeps the operator-visible answer
|
|
298
|
+
* from drifting apart between native pi and the MCP bridge.
|
|
299
|
+
*
|
|
300
|
+
* The success text states the boundary out loud. A caller that reads "launched" and assumes
|
|
301
|
+
* "delivered" is the exact confusion the two-receipt split exists to prevent, so the text refuses
|
|
302
|
+
* to imply it.
|
|
303
|
+
*/
|
|
304
|
+
export function renderFreshCall(result: FreshCallResult): { text: string; isError: boolean } {
|
|
305
|
+
if (!result.ok) {
|
|
306
|
+
return {
|
|
307
|
+
text: `entwurf_fresh_call rejected: ${result.reason} — ${REJECT_HINT[result.reason]}. No window was opened.`,
|
|
308
|
+
isError: true,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const r = result.receipt;
|
|
312
|
+
return {
|
|
313
|
+
text:
|
|
314
|
+
`[entwurf fresh call →]\n` +
|
|
315
|
+
` backend: ${r.backend} (${r.runtimePath})\n` +
|
|
316
|
+
` model: ${r.model} (requested on the runtime CLI)\n` +
|
|
317
|
+
` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
|
|
318
|
+
` pane: ${r.paneId} pid ${r.panePid}\n` +
|
|
319
|
+
` nonce: ${r.nonce}\n` +
|
|
320
|
+
`\n` +
|
|
321
|
+
`This is a LAUNCH receipt: tmux created that window and was asked to start the runtime with the model above. It does ` +
|
|
322
|
+
`NOT mean the sibling is running, that its first turn ran, or that the task was delivered.\n` +
|
|
323
|
+
`The sibling's garden id arrives separately — it calls entwurf_v2 back with the nonce above as its ` +
|
|
324
|
+
`first action, and the sender envelope of THAT message is the address. Nothing is polling for it; ` +
|
|
325
|
+
`if it never comes, the window is visible and can be read directly.`,
|
|
326
|
+
isError: false,
|
|
327
|
+
};
|
|
328
|
+
}
|