@junghanacs/entwurf 0.35.1 → 0.36.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 +2 -2
- package/CHANGELOG.md +209 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +15 -0
- package/FAQ.md +3 -3
- package/README.md +30 -16
- package/VERIFY.md +9 -7
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/setup-clean-host.md +30 -22
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +59 -14
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
- package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
- package/package.json +19 -14
- package/pi/pi-durable/bootstrap.mjs +12 -8
- package/pi/pi-durable/carrier/runtime.js +52 -2
- package/pi/pi-durable/carrier/subagent.js +6 -1
- package/pi/pi-durable/carrier/tui.js +31 -4
- package/pi/pi-durable/carrier-relocation.mjs +2 -2
- package/pi/pi-durable/overlay/upstream-pin.json +79 -36
- package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
- package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
- package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
- package/pi-extensions/meta-bridge-pi-durable.ts +77 -21
- package/run.sh +112 -38
- package/scripts/check-codex-app-server-launch.ts +55 -12
- package/scripts/check-codex-native-push.ts +231 -12
- package/scripts/check-gate-qualification.ts +3 -3
- package/scripts/codex-app-server-launch.sh +54 -29
- package/scripts/codex-socket-classify.ts +46 -0
- package/scripts/emit-pi-durable-carrier.ts +159 -168
- package/scripts/mutants/codex-app-server-launch.json +39 -12
- package/scripts/mutants/codex-native.json +15 -0
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-durable.json +165 -16
- package/scripts/mutants/pi-floor.json +5 -5
- package/scripts/pi-durable-contact-host.mjs +1 -1
- package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
|
@@ -15,30 +15,108 @@ export function resolveCodexHome(env) {
|
|
|
15
15
|
export function resolveCodexDefaultSocketPath(env) {
|
|
16
16
|
return path.join(resolveCodexHome(env), "app-server-control", "app-server-control.sock");
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The advertised path is exactly one of two layouts, and anything else is refused.
|
|
20
|
+
*
|
|
21
|
+
* (A) A plain socket owned by the effective uid: Codex up to 0.153.4 binds the advertised
|
|
22
|
+
* path itself. `connectPath` is that path.
|
|
23
|
+
* (B) Codex's rendezvous alias. From 0.162.1 the advertised path "is only an alias": the
|
|
24
|
+
* server binds a 0600 socket under `realpath(/tmp)/codex-daemon-<euid>/` named by the
|
|
25
|
+
* sha256 of the canonical advertised path, then symlinks the advertised path to it
|
|
26
|
+
* (`[source]` rust-v0.162.1 `app-server-transport/src/transport/unix_socket.rs:43-131,
|
|
27
|
+
* 290-304`; `uds/src/daemon_directory.rs:1-38`). Upstream's reason is a privileged
|
|
28
|
+
* rendezvous root that sandboxes hide; the alias also keeps an over-long advertised path
|
|
29
|
+
* usable (`uds/src/lib.rs:167-174`). The alias is accepted only when every hop is the
|
|
30
|
+
* vendor's own: the link is owned by the effective uid; its target is BYTE-EQUAL to the
|
|
31
|
+
* path Codex derives (a relative, foreign, re-hashed or chained target is refused); the
|
|
32
|
+
* daemon directory is a real directory owned by the effective uid with `mode & 0o777`
|
|
33
|
+
* exactly 0700, the vendor's own rule; and the target is a socket owned by the effective
|
|
34
|
+
* uid that no group or other user can open. `connectPath` is the verified target, so the
|
|
35
|
+
* alias is never re-resolved between this check and the connect. A same-euid process can
|
|
36
|
+
* still replace the verified socket file itself; that residual is named, not locked.
|
|
37
|
+
*
|
|
38
|
+
* Only `lstat` and one `readlink` touch the alias; it is never followed or `realpath`ed.
|
|
39
|
+
* The effective uid is the vendor's (`libc::geteuid`), not the real uid.
|
|
40
|
+
*/
|
|
41
|
+
export function checkCodexSocketFile(socketPath, deps = {}) {
|
|
42
|
+
const euid = deps.euid ?? process.geteuid?.();
|
|
43
|
+
let stat;
|
|
19
44
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
return { ok: false, status: "
|
|
45
|
+
stat = fs.lstatSync(socketPath);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const code = error.code;
|
|
49
|
+
if (code === "ENOENT") {
|
|
50
|
+
return { ok: false, status: "dead", layout: "absent", reason: `app-server socket is absent: ${socketPath}` };
|
|
26
51
|
}
|
|
27
|
-
|
|
52
|
+
return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
|
|
53
|
+
}
|
|
54
|
+
if (stat.isSymbolicLink())
|
|
55
|
+
return checkCodexRendezvousAlias(socketPath, stat, euid, deps);
|
|
56
|
+
if (!stat.isSocket()) {
|
|
57
|
+
return { ok: false, status: "indeterminate", reason: `app-server path is not a socket: ${socketPath}` };
|
|
58
|
+
}
|
|
59
|
+
if (euid !== undefined && stat.uid !== euid) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
status: "indeterminate",
|
|
63
|
+
reason: `app-server socket is owned by uid ${stat.uid}, not the effective uid ${euid}`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, connectPath: socketPath };
|
|
67
|
+
}
|
|
68
|
+
function checkCodexRendezvousAlias(socketPath, link, euid, deps) {
|
|
69
|
+
const refuse = (reason) => ({ ok: false, status: "indeterminate", reason });
|
|
70
|
+
if (euid === undefined) {
|
|
71
|
+
return refuse(`app-server socket is a symlink and this platform has no effective uid to verify it: ${socketPath}`);
|
|
72
|
+
}
|
|
73
|
+
if (link.uid !== euid) {
|
|
74
|
+
return refuse(`app-server socket symlink is owned by uid ${link.uid}, not the effective uid ${euid}`);
|
|
75
|
+
}
|
|
76
|
+
let daemonRoot;
|
|
77
|
+
let expected;
|
|
78
|
+
let target;
|
|
79
|
+
try {
|
|
80
|
+
daemonRoot = deps.daemonRoot ?? path.join(fs.realpathSync("/tmp"), `codex-daemon-${euid}`);
|
|
81
|
+
const canonical = path.join(fs.realpathSync(path.dirname(socketPath)), path.basename(socketPath));
|
|
82
|
+
expected = path.join(daemonRoot, crypto.createHash("sha256").update(canonical).digest("hex"));
|
|
83
|
+
target = fs.readlinkSync(socketPath);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return refuse(`could not verify the app-server socket symlink ${socketPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
87
|
+
}
|
|
88
|
+
if (target !== expected) {
|
|
89
|
+
return refuse(`app-server socket is a symlink that is not Codex's rendezvous alias: ${socketPath} -> ${target}`);
|
|
90
|
+
}
|
|
91
|
+
let directory;
|
|
92
|
+
try {
|
|
93
|
+
directory = fs.lstatSync(daemonRoot);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
return refuse(`Codex's socket directory cannot be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
97
|
+
}
|
|
98
|
+
if (!directory.isDirectory() || directory.uid !== euid || (directory.mode & 0o777) !== 0o700) {
|
|
99
|
+
return refuse(`Codex's socket directory ${daemonRoot} must be a real directory owned by uid ${euid} with mode 0700`);
|
|
100
|
+
}
|
|
101
|
+
let physical;
|
|
102
|
+
try {
|
|
103
|
+
physical = fs.lstatSync(expected);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.code === "ENOENT") {
|
|
28
107
|
return {
|
|
29
108
|
ok: false,
|
|
30
|
-
status: "
|
|
31
|
-
|
|
109
|
+
status: "dead",
|
|
110
|
+
layout: "dangling-alias",
|
|
111
|
+
reason: `app-server socket is a dangling rendezvous alias: ${socketPath} -> ${expected}`,
|
|
32
112
|
};
|
|
33
113
|
}
|
|
34
|
-
return
|
|
114
|
+
return refuse(error instanceof Error ? error.message : String(error));
|
|
35
115
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (code === "ENOENT")
|
|
39
|
-
return { ok: false, status: "dead", reason: `app-server socket is absent: ${socketPath}` };
|
|
40
|
-
return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
|
|
116
|
+
if (!physical.isSocket() || physical.uid !== euid || (physical.mode & 0o077) !== 0) {
|
|
117
|
+
return refuse(`Codex's physical socket ${expected} must be a socket owned by uid ${euid} that no group or other user can open`);
|
|
41
118
|
}
|
|
119
|
+
return { ok: true, connectPath: expected };
|
|
42
120
|
}
|
|
43
121
|
function promiseResolvers() {
|
|
44
122
|
return Promise.withResolvers();
|
|
@@ -302,10 +380,13 @@ const realCodexExecRunner = {
|
|
|
302
380
|
},
|
|
303
381
|
};
|
|
304
382
|
export async function probeCodexThread(nativeSessionId, deps = {}) {
|
|
305
|
-
const
|
|
306
|
-
const socketCheck = (deps.checkSocket ?? checkCodexSocketFile)(socketPath);
|
|
383
|
+
const socketCheck = (deps.checkSocket ?? checkCodexSocketFile)(resolveCodexDefaultSocketPath(deps.env ?? process.env));
|
|
307
384
|
if (!socketCheck.ok)
|
|
308
385
|
return { status: socketCheck.status, reason: socketCheck.reason };
|
|
386
|
+
// The probe, and the queue send its route carries, both use the endpoint just verified. The
|
|
387
|
+
// advertised path is not resolved again: on 0.162.1 it is an alias that could be retargeted
|
|
388
|
+
// between the check and a second transport.
|
|
389
|
+
const socketPath = socketCheck.connectPath;
|
|
309
390
|
let protocol;
|
|
310
391
|
try {
|
|
311
392
|
protocol = await (deps.openProtocol ?? realCodexProtocolOpener).open(socketPath);
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* under (#129 W, #130).
|
|
4
4
|
*
|
|
5
5
|
* The experimental durable app is source-only upstream: published `@earendil-works/pi-coding-agent`
|
|
6
|
-
* ships no `dist/experimental`. Entwurf therefore ships the app itself as a CARRIER —
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* ships no `dist/experimental`. Entwurf therefore ships the app itself as a CARRIER — its owned
|
|
7
|
+
* source (pi/pi-durable/src, #141), type-stripped and relocated (pi/pi-durable/carrier/, emitted only
|
|
8
|
+
* by scripts/emit-pi-durable-carrier.ts) — and declares the published SDK set it runs
|
|
9
9
|
* against as exact production dependencies. Nothing is provisioned beside the package: there is no
|
|
10
10
|
* runtime directory, no source checkout and no model data to copy (it rides the published pi-ai).
|
|
11
11
|
*
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* WHAT THIS IS. The long-running durable app (`@earendil-works/pi-coding-agent`
|
|
5
5
|
* `experimental/durable`, which upstream omits from its npm distribution — Entwurf's package
|
|
6
|
-
* supplies it as a
|
|
7
|
-
* models/auth, storage, prompt and recovery. This module is
|
|
6
|
+
* supplies it as a carrier emitted from its owned source, #130/#141, not as an operator
|
|
7
|
+
* prerequisite) owns its runtime, TUI, models/auth, storage, prompt and recovery. This module is
|
|
8
|
+
* the whole Entwurf side of its contact:
|
|
8
9
|
*
|
|
9
10
|
* createPiDurableContact({ bridgeEntry })
|
|
10
11
|
* .extension tools-only durable Extension, handed to the overlay's
|
|
@@ -49,14 +50,16 @@
|
|
|
49
50
|
* bridge whose list differs: the callable bridge schema is the truth, the copy only has to agree.
|
|
50
51
|
*
|
|
51
52
|
* RECEIVE (self-fetch, announce-only). When the bootstrap hands `attach` the overlay's
|
|
52
|
-
* `submitToRoot`, the contact arms a doorbell after the bridge is ready: mailbox, then signal,
|
|
53
|
+
* `submitToRoot` and `onFailed`, the contact arms a doorbell after the bridge is ready: mailbox, then signal,
|
|
53
54
|
* then the watch, then a check that the record and this process's sender marker still name the
|
|
54
55
|
* session, and only then the receiver marker. A fresh `.msg` is stamped `.msg.delivered` and
|
|
55
56
|
* announced to the ROOT conversation as input; the body stays in the mailbox until the model
|
|
56
57
|
* calls `entwurf_inbox_read`, which is the read receipt. The watch and the sender marker share
|
|
57
58
|
* this process's pid, so the #101 owner join applies (`pi-durable-host`). A watch error, a
|
|
58
|
-
* vanished signal, a failed ring, a rejected admission
|
|
59
|
-
*
|
|
59
|
+
* vanished signal, a failed ring, a rejected admission, the session failing (at once, from the
|
|
60
|
+
* overlay's `onFailed`, not at the next ring — and before `attach` returns when it had already
|
|
61
|
+
* failed, with no receiver marker left behind) or close retires the doorbell — watch closed, our own
|
|
62
|
+
* marker removed, the reason kept by name — and nothing retries it.
|
|
60
63
|
*
|
|
61
64
|
* WITHDRAW (#140). Each notice is a native `followUp`, placed one per run; a read drains EVERY
|
|
62
65
|
* unread body. So after a read the notices still queued for that mail would each cost a turn and an
|
|
@@ -484,7 +487,7 @@ export function createPiDurableContact(options) {
|
|
|
484
487
|
const result = name === "entwurf_inbox_read" ? await readInbox(call, context) : await call();
|
|
485
488
|
if (result.isError)
|
|
486
489
|
throw new Error(result.text || `${name} failed`);
|
|
487
|
-
return {
|
|
490
|
+
return { output: [{ type: "text", text: result.text }] };
|
|
488
491
|
},
|
|
489
492
|
});
|
|
490
493
|
const extension = {
|
|
@@ -534,6 +537,7 @@ export function createPiDurableContact(options) {
|
|
|
534
537
|
if (armed === null)
|
|
535
538
|
return [];
|
|
536
539
|
doorbell = null;
|
|
540
|
+
armed.unsubscribeFailed();
|
|
537
541
|
const problems = [];
|
|
538
542
|
try {
|
|
539
543
|
armed.watcher.close();
|
|
@@ -686,9 +690,10 @@ export function createPiDurableContact(options) {
|
|
|
686
690
|
* Mailbox, signal, watch — then the check that the record and this process's sender marker
|
|
687
691
|
* still name the session — and only then the receiver marker, the one fact a sender reads.
|
|
688
692
|
* Synchronous, so nothing can close the contact between the check and the publish. Throws,
|
|
689
|
-
* after closing whatever it opened, when any step fails.
|
|
693
|
+
* after closing whatever it opened, when any step fails. Returns null when the session had
|
|
694
|
+
* already failed: the doorbell was retired as `session-failed` before this returned.
|
|
690
695
|
*/
|
|
691
|
-
const arm = (gardenId, nativeSessionId, ownerPid, roots,
|
|
696
|
+
const arm = (gardenId, nativeSessionId, ownerPid, roots, root) => {
|
|
692
697
|
if (ownerPid !== process.pid) {
|
|
693
698
|
throw new Error(`the doorbell's watch lives in this process (${process.pid}); a receiver marker naming owner ${ownerPid} would advertise a watch that pid does not hold`);
|
|
694
699
|
}
|
|
@@ -698,6 +703,7 @@ export function createPiDurableContact(options) {
|
|
|
698
703
|
if (!fs.existsSync(signal))
|
|
699
704
|
fs.writeFileSync(signal, "", { mode: 0o600 });
|
|
700
705
|
const watcher = watch(signal, () => void ring("signal"));
|
|
706
|
+
let unsubscribeFailed = () => { };
|
|
701
707
|
try {
|
|
702
708
|
watcher.on("error", (error) => retire("watch-error", describeError(error)));
|
|
703
709
|
const identity = readAddressableMetaIdentity(gardenId, roots.sessionsDir);
|
|
@@ -710,6 +716,22 @@ export function createPiDurableContact(options) {
|
|
|
710
716
|
? `no live sender marker for owner ${ownerPid}`
|
|
711
717
|
: `the sender marker for owner ${ownerPid} names ${sender.gardenId}/${sender.nativeSessionId}, not ${gardenId}/${nativeSessionId}`);
|
|
712
718
|
}
|
|
719
|
+
// A failed session admits nothing: retire at once, so no sender reads a marker for a host that cannot wake.
|
|
720
|
+
// Subscribed before the marker is written, so a registration that throws leaves no marker. A session that
|
|
721
|
+
// failed before this arm says so during the subscription, before the doorbell exists: that is noted, and
|
|
722
|
+
// the retirement runs once the doorbell is published below. It is final; nothing re-arms.
|
|
723
|
+
let published = false;
|
|
724
|
+
let failedBeforePublished = false;
|
|
725
|
+
const sessionFailed = () => {
|
|
726
|
+
if (!published) {
|
|
727
|
+
failedBeforePublished = true;
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const problems = retire("session-failed", "the durable session failed and closed itself");
|
|
731
|
+
if (problems.length > 0)
|
|
732
|
+
throw new Error(`the doorbell did not retire cleanly: ${problems.join("; ")}`);
|
|
733
|
+
};
|
|
734
|
+
unsubscribeFailed = root.onFailed(sessionFailed);
|
|
713
735
|
const markerPath = writeMetaReceiverMarker({
|
|
714
736
|
gardenId,
|
|
715
737
|
backend: PI_DURABLE_BACKEND,
|
|
@@ -719,17 +741,37 @@ export function createPiDurableContact(options) {
|
|
|
719
741
|
armProvenance: "session-start",
|
|
720
742
|
receiversDir: roots.receiversDir,
|
|
721
743
|
});
|
|
722
|
-
doorbell = {
|
|
744
|
+
doorbell = {
|
|
745
|
+
gardenId,
|
|
746
|
+
ownerPid,
|
|
747
|
+
mailbox,
|
|
748
|
+
signal,
|
|
749
|
+
markerPath,
|
|
750
|
+
watcher,
|
|
751
|
+
submitToRoot: root.submitToRoot,
|
|
752
|
+
unsubscribeFailed,
|
|
753
|
+
};
|
|
723
754
|
inbox = mailbox;
|
|
724
755
|
report = { status: "armed", gardenId, markerPath, admissions };
|
|
756
|
+
published = true;
|
|
757
|
+
if (failedBeforePublished) {
|
|
758
|
+
sessionFailed();
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
725
761
|
return markerPath;
|
|
726
762
|
}
|
|
727
763
|
catch (error) {
|
|
728
|
-
|
|
729
|
-
|
|
764
|
+
const cleanup = [];
|
|
765
|
+
for (const step of [unsubscribeFailed, () => watcher.close()]) {
|
|
766
|
+
try {
|
|
767
|
+
step();
|
|
768
|
+
}
|
|
769
|
+
catch (cleanupError) {
|
|
770
|
+
cleanup.push(cleanupError);
|
|
771
|
+
}
|
|
730
772
|
}
|
|
731
|
-
|
|
732
|
-
throw new AggregateError([error,
|
|
773
|
+
if (cleanup.length > 0) {
|
|
774
|
+
throw new AggregateError([error, ...cleanup], `${describeError(error)} (cleanup also failed: ${cleanup.map(describeError).join("; ")})`);
|
|
733
775
|
}
|
|
734
776
|
throw error;
|
|
735
777
|
}
|
|
@@ -757,6 +799,9 @@ export function createPiDurableContact(options) {
|
|
|
757
799
|
if (root !== undefined && typeof root.submitToRoot !== "function") {
|
|
758
800
|
throw new Error("the root admission handed to attach has no submitToRoot function");
|
|
759
801
|
}
|
|
802
|
+
if (root !== undefined && typeof root.onFailed !== "function") {
|
|
803
|
+
throw new Error("the root admission handed to attach has no onFailed function");
|
|
804
|
+
}
|
|
760
805
|
const ownerPid = options.ownerPid ?? process.pid;
|
|
761
806
|
const sendersDir = path.resolve(options.sendersDir ?? defaultMetaSendersDir());
|
|
762
807
|
const upsert = upsertMetaSession({
|
|
@@ -806,7 +851,7 @@ export function createPiDurableContact(options) {
|
|
|
806
851
|
let receiverMarkerPath = null;
|
|
807
852
|
if (root !== undefined) {
|
|
808
853
|
try {
|
|
809
|
-
receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root
|
|
854
|
+
receiverMarkerPath = arm(gardenId, id, ownerPid, roots, root);
|
|
810
855
|
}
|
|
811
856
|
catch (error) {
|
|
812
857
|
return await failAfterCleanup(new Error(`the doorbell did not arm: ${describeError(error)}`), () => client.close());
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex-socket-classify — classify the Codex app-server default control socket for THIS
|
|
3
|
+
* environment, through the one product classifier, and print the answer for a shell.
|
|
4
|
+
*
|
|
5
|
+
* WHY A LEAF. `entwurf codex-app-server` is a bash launcher, and it has to decide whether a
|
|
6
|
+
* launch would clobber something. From Codex 0.162.1 the advertised socket path is a
|
|
7
|
+
* rendezvous alias into a vendor-owned directory, and telling the vendor's exact alias from any
|
|
8
|
+
* other symlink takes a hash, an exact readlink comparison and ownership/mode checks. Doing that
|
|
9
|
+
* in the launcher's Python would be a second implementation of a security classification, and
|
|
10
|
+
* `codex-socket-path.ts` records what a second spelling of this socket did once (it diverged).
|
|
11
|
+
* So the launcher asks `checkCodexSocketFile`, the classifier delivery and preflight use, and
|
|
12
|
+
* only connects to what it was told.
|
|
13
|
+
*
|
|
14
|
+
* HOW IT IS REACHED. The launcher runs this file's COMPILED twin,
|
|
15
|
+
* `mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js`, directly with `node` — in a checkout
|
|
16
|
+
* and in an installed package alike. There is no `run.sh` verb and no `run_ts` crossing for it:
|
|
17
|
+
* `build-bridge` emits it (`pnpm install` runs that through `prepare`, packing through `prepack`),
|
|
18
|
+
* and a missing twin is an incomplete installation the launcher refuses by name.
|
|
19
|
+
*
|
|
20
|
+
* One line on stdout, a word then a tab-separated detail:
|
|
21
|
+
* socket <connectPath> a verified socket; the launcher connects there to tell live from stale
|
|
22
|
+
* absent nothing at the advertised path
|
|
23
|
+
* dangling <reason> Codex's own alias whose socket is gone; the vendor removes it on start
|
|
24
|
+
* indeterminate <reason> anything else; the launcher refuses
|
|
25
|
+
*
|
|
26
|
+
* Reads `process.env` and takes no argument, for the reason `codex-socket-path.ts` gives: an argv
|
|
27
|
+
* seam would be a way to redirect the address. It has no daemon-root input either; the gate's
|
|
28
|
+
* exact-alias cells drive `checkCodexSocketFile` directly with a sandbox root.
|
|
29
|
+
*/
|
|
30
|
+
import { checkCodexSocketFile, resolveCodexDefaultSocketPath, } from "../pi-extensions/lib/native-push/codex-ws-client.js";
|
|
31
|
+
if (process.argv.length > 2) {
|
|
32
|
+
console.error("usage: codex-socket-classify (no arguments; the environment is the input)");
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
const oneLine = (text) => text.replace(/[\t\r\n]+/g, " ");
|
|
36
|
+
const check = checkCodexSocketFile(resolveCodexDefaultSocketPath(process.env));
|
|
37
|
+
if (check.ok)
|
|
38
|
+
process.stdout.write(`socket\t${check.connectPath}`);
|
|
39
|
+
else if (check.status === "dead" && check.layout === "absent")
|
|
40
|
+
process.stdout.write("absent");
|
|
41
|
+
else if (check.status === "dead")
|
|
42
|
+
process.stdout.write(`dangling\t${oneLine(check.reason)}`);
|
|
43
|
+
else
|
|
44
|
+
process.stdout.write(`indeterminate\t${oneLine(check.reason)}`);
|
|
@@ -122,6 +122,10 @@
|
|
|
122
122
|
// twin would make the one-command launch die before its first line. Its only repo import is
|
|
123
123
|
// native-push/codex-ws-client.ts, ALREADY emitted for meta-bridge-hook-codex, so this adds ONE
|
|
124
124
|
// leaf JS and ZERO new deps.
|
|
125
|
+
// WHY codex-socket-classify rides THIS build (#141): the same launcher asks it whether a launch
|
|
126
|
+
// would clobber something, and runs THIS compiled twin directly with `node` in a checkout and an
|
|
127
|
+
// installed package alike (no run.sh verb, no run_ts crossing), so the emitted file is the only
|
|
128
|
+
// route. Its only repo import is that same codex-ws-client.ts: ONE leaf JS, ZERO new deps.
|
|
125
129
|
// WHY meta-bridge-pi-durable rides THIS build (#129 W, L4): the packaged durable bootstrap
|
|
126
130
|
// (pi/pi-durable/bootstrap.mjs) imports the contact from THIS closure — the same closure whose
|
|
127
131
|
// bridge entry the contact spawns and whose meta-bridge-omp.js decodes the first-input payload —
|
|
@@ -151,6 +155,7 @@
|
|
|
151
155
|
"../../scripts/peer-facts.ts",
|
|
152
156
|
"../../scripts/omp-receive-facts.ts",
|
|
153
157
|
"../../scripts/codex-socket-path.ts",
|
|
158
|
+
"../../scripts/codex-socket-classify.ts",
|
|
154
159
|
"../../pi-extensions/meta-bridge-hook.ts",
|
|
155
160
|
"../../pi-extensions/meta-bridge-hook-copilot.ts",
|
|
156
161
|
"../../pi-extensions/meta-bridge-hook-codex.ts",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junghanacs/entwurf",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.36.0",
|
|
4
|
+
"description": "Visible sibling sessions across independent agent harnesses: launch in Herdr or tmux, reopen pi in tmux, and deliver messages by garden id; native bridges and Claude/Cortex ACP adapters.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Junghan Kim",
|
|
@@ -30,7 +30,12 @@
|
|
|
30
30
|
"copilot",
|
|
31
31
|
"antigravity",
|
|
32
32
|
"pi-coding-agent",
|
|
33
|
-
"pi-extension"
|
|
33
|
+
"pi-extension",
|
|
34
|
+
"herdr",
|
|
35
|
+
"tmux",
|
|
36
|
+
"visible-sessions",
|
|
37
|
+
"session-lifecycle",
|
|
38
|
+
"multi-agent"
|
|
34
39
|
],
|
|
35
40
|
"type": "module",
|
|
36
41
|
"files": [
|
|
@@ -59,7 +64,6 @@
|
|
|
59
64
|
"pi/pi-durable/carrier-resolver.mjs",
|
|
60
65
|
"pi/pi-durable/carrier/",
|
|
61
66
|
"pi/pi-durable/overlay/upstream-pin.json",
|
|
62
|
-
"pi/pi-durable/overlay/runtime-contacts.patch",
|
|
63
67
|
"pi/meta-bridge/.claude-plugin/marketplace.json",
|
|
64
68
|
"pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json",
|
|
65
69
|
"pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json",
|
|
@@ -95,15 +99,15 @@
|
|
|
95
99
|
"@agentclientprotocol/claude-agent-acp": "0.85.1",
|
|
96
100
|
"@agentclientprotocol/sdk": "1.6.0",
|
|
97
101
|
"@anthropic-ai/sdk": "0.100.1",
|
|
98
|
-
"@earendil-works/chord": "1.
|
|
99
|
-
"@earendil-works/pi-agent-core": "1.
|
|
100
|
-
"@earendil-works/pi-ai": "1.
|
|
101
|
-
"@earendil-works/pi-codemode": "1.
|
|
102
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
103
|
-
"@earendil-works/pi-durable": "1.
|
|
104
|
-
"@earendil-works/pi-mcp": "1.
|
|
105
|
-
"@earendil-works/pi-telemetry": "1.
|
|
106
|
-
"@earendil-works/pi-tui": "1.
|
|
102
|
+
"@earendil-works/chord": "1.2.0",
|
|
103
|
+
"@earendil-works/pi-agent-core": "1.2.0",
|
|
104
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
105
|
+
"@earendil-works/pi-codemode": "1.2.0",
|
|
106
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
107
|
+
"@earendil-works/pi-durable": "1.2.0",
|
|
108
|
+
"@earendil-works/pi-mcp": "1.2.0",
|
|
109
|
+
"@earendil-works/pi-telemetry": "1.2.0",
|
|
110
|
+
"@earendil-works/pi-tui": "1.2.0",
|
|
107
111
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
108
112
|
"proper-lockfile": "4.1.2",
|
|
109
113
|
"smol-toml": "^1.8.0",
|
|
@@ -115,6 +119,7 @@
|
|
|
115
119
|
"devDependencies": {
|
|
116
120
|
"@biomejs/biome": "^2.4.13",
|
|
117
121
|
"@types/node": "^24.3.0",
|
|
122
|
+
"@types/proper-lockfile": "4.1.4",
|
|
118
123
|
"husky": "^9.1.7",
|
|
119
124
|
"rregex": "1.13.1",
|
|
120
125
|
"typescript": "^6.0.2",
|
|
@@ -178,7 +183,7 @@
|
|
|
178
183
|
"test": "vitest run",
|
|
179
184
|
"test:watch": "vitest",
|
|
180
185
|
"test:pack": "pnpm run check-pack && pnpm run check-pack-install",
|
|
181
|
-
"typecheck": "tsc --noEmit && tsc -p mcp/tsconfig.json && tsc -p scripts/tsconfig.json",
|
|
186
|
+
"typecheck": "tsc --noEmit && tsc -p mcp/tsconfig.json && tsc -p scripts/tsconfig.json && tsc -p pi/pi-durable/tsconfig.json",
|
|
182
187
|
"lint": "biome check .",
|
|
183
188
|
"format": "biome check --write .",
|
|
184
189
|
"check-meta-manifest-schema": "./run.sh check-meta-manifest-schema",
|
|
@@ -9,15 +9,16 @@
|
|
|
9
9
|
// with these differences:
|
|
10
10
|
// - Entwurf's tools-only extension is handed to `openDurable`;
|
|
11
11
|
// - a NEW session is opened with an explicit model and an explicit width, never a default (below);
|
|
12
|
-
// - the citizen is born once `openDurable` returns, with the
|
|
13
|
-
// doorbell can announce mail to the root conversation
|
|
12
|
+
// - the citizen is born once `openDurable` returns, with the owned runtime's `submitToRoot` so its
|
|
13
|
+
// doorbell can announce mail to the root conversation, and its `onFailed` so a session that
|
|
14
|
+
// fails and closes itself retires that doorbell at once;
|
|
14
15
|
// - a launcher's first input, when given, is admitted to the root ONCE, after that birth;
|
|
15
16
|
// - the TUI is handed the native view with the garden id on every conversation label
|
|
16
17
|
// (`withGardenIdentity`).
|
|
17
18
|
// Nothing of the native registry, controller or TUI is rebuilt here.
|
|
18
19
|
//
|
|
19
|
-
// The durable app itself is this package's CARRIER (./carrier/,
|
|
20
|
-
//
|
|
20
|
+
// The durable app itself is this package's CARRIER (./carrier/, emitted from Entwurf's owned source
|
|
21
|
+
// pi/pi-durable/src — upstream publishes no experimental app), and it runs under the carrier's resolver,
|
|
21
22
|
// which maps the app's pi-coding-agent internals to the published package the carrier resolves.
|
|
22
23
|
// `entwurf pi-durable` (run.sh) verifies the carrier and its SDK set (pi-durable-runtime.ts) and
|
|
23
24
|
// execs:
|
|
@@ -29,11 +30,11 @@
|
|
|
29
30
|
//
|
|
30
31
|
// Explicit means the native API, not a vendor argv: upstream's durable main takes only
|
|
31
32
|
// `--continue`, so `--provider`/`--model` reach pi's own `findInitialAgentModel` through the
|
|
32
|
-
//
|
|
33
|
+
// owned runtime's `model` option, for a new session only, and an unresolvable pair fails the open
|
|
33
34
|
// before any citizen is born (the native session directory may already exist by then). The model
|
|
34
35
|
// must be one registered catalog model of that provider, named by its canonical id or by
|
|
35
36
|
// `id:<level>` as pi's own parser reads a thinking level: a typo, a partial or case-variant match, a
|
|
36
|
-
// provider-prefixed id or an unregistered custom-id fallback is refused by the
|
|
37
|
+
// provider-prefixed id or an unregistered custom-id fallback is refused by the owned runtime as
|
|
37
38
|
// `explicit-model-not-exact`, before the birth and the first input.
|
|
38
39
|
// `--width task-wide` is the one width: no tool filter, so the model is offered exactly the tools
|
|
39
40
|
// the installed extensions register. It is a statement of what is OFFERED, not an approval or
|
|
@@ -50,7 +51,7 @@
|
|
|
50
51
|
// its initialization, and its default export is a native durable Extension, handed to `openDurable`
|
|
51
52
|
// by reference after the contact. Initialization runs before the TUI or the runtime is imported —
|
|
52
53
|
// the runtime creates the provider runtime before it installs any extension (carrier/runtime.js
|
|
53
|
-
//
|
|
54
|
+
// 153-159), so an extension object cannot carry an environment phase. Entwurf checks only what the
|
|
54
55
|
// ingress needs: a default export object with a name, no extension name the app installs itself (a
|
|
55
56
|
// same-name install REPLACES), and no tool name of the contact. Every other field is the native
|
|
56
57
|
// registry's to validate. The module is trusted operator code: the identity carriers and the
|
|
@@ -341,7 +342,10 @@ export async function openDurableCitizen({
|
|
|
341
342
|
}
|
|
342
343
|
let attachment;
|
|
343
344
|
try {
|
|
344
|
-
attachment = await contact.attach(durable.view.current().session, {
|
|
345
|
+
attachment = await contact.attach(durable.view.current().session, {
|
|
346
|
+
submitToRoot: durable.submitToRoot,
|
|
347
|
+
onFailed: durable.onFailed,
|
|
348
|
+
});
|
|
345
349
|
} catch (error) {
|
|
346
350
|
return failAfterCleanup(
|
|
347
351
|
error,
|
|
@@ -62,6 +62,11 @@ const context = BACKGROUND_CONTEXT;
|
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
65
70
|
|
|
66
71
|
|
|
67
72
|
|
|
@@ -98,6 +103,12 @@ const context = BACKGROUND_CONTEXT;
|
|
|
98
103
|
|
|
99
104
|
|
|
100
105
|
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
101
112
|
|
|
102
113
|
|
|
103
114
|
|
|
@@ -234,8 +245,27 @@ export async function openDurable(options = {})
|
|
|
234
245
|
update({ notices: [...state.notices, { id: nextNotice++, level, message }].slice(-20) });
|
|
235
246
|
};
|
|
236
247
|
const fail = (error ) => notice("error", error instanceof Error ? error.message : String(error));
|
|
237
|
-
report = (error) =>
|
|
248
|
+
report = (error) => {
|
|
249
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
250
|
+
// A failing Session runs its close listeners first and reports the cause right after: that report names it.
|
|
251
|
+
if (state.failure !== undefined && state.failure.error === undefined) update({ failure: { error: message } });
|
|
252
|
+
else notice("warning", message);
|
|
253
|
+
};
|
|
238
254
|
for (const error of pendingReports) report(error);
|
|
255
|
+
const failedListeners = new Set ();
|
|
256
|
+
// The Session closes itself when its Storage fails. Its `closed` settles only after every running task returned,
|
|
257
|
+
// which a task ignoring its abort can delay without end, so the failure is shown from the close listener itself.
|
|
258
|
+
// `close()` unsubscribes before it closes the Session, so only a Session closing itself gets here.
|
|
259
|
+
const unsubscribeClose = harness.subscribeClose(() => {
|
|
260
|
+
update({ failure: {} });
|
|
261
|
+
for (const listener of failedListeners) {
|
|
262
|
+
try {
|
|
263
|
+
listener();
|
|
264
|
+
} catch (error) {
|
|
265
|
+
fail(error);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
});
|
|
239
269
|
let unsubscribe = conversation.subscribe((value) => update({ conversation: value }));
|
|
240
270
|
// Subagents appear as their conversations are created. A commit listener only records; it calls no Session API.
|
|
241
271
|
const unsubscribeCommits = harness.subscribeCommits((publication) => {
|
|
@@ -286,7 +316,12 @@ export async function openDurable(options = {})
|
|
|
286
316
|
};
|
|
287
317
|
const controller = {
|
|
288
318
|
submit: (text, whenBusy) =>
|
|
289
|
-
command(async () =>
|
|
319
|
+
command(async () => {
|
|
320
|
+
if (state.failure !== undefined) {
|
|
321
|
+
throw new Error("The session failed and takes no input. Close it, then reopen it with --continue.");
|
|
322
|
+
}
|
|
323
|
+
watchAnswer(await current.submit({ type: "input", content: text, whenBusy }, context));
|
|
324
|
+
}),
|
|
290
325
|
compact: (instructions) =>
|
|
291
326
|
command(async () => {
|
|
292
327
|
const id = await current.compact(instructions, context);
|
|
@@ -374,8 +409,23 @@ export async function openDurable(options = {})
|
|
|
374
409
|
controller,
|
|
375
410
|
settings: settingsManager,
|
|
376
411
|
submitToRoot: (draft) => root.submit(draft, context),
|
|
412
|
+
onFailed(listener) {
|
|
413
|
+
// The failure is kept in the view, so a listener that comes after it is told at once, and still only once.
|
|
414
|
+
if (state.failure !== undefined) {
|
|
415
|
+
try {
|
|
416
|
+
listener();
|
|
417
|
+
} catch (error) {
|
|
418
|
+
fail(error);
|
|
419
|
+
}
|
|
420
|
+
return () => {};
|
|
421
|
+
}
|
|
422
|
+
failedListeners.add(listener);
|
|
423
|
+
return () => failedListeners.delete(listener);
|
|
424
|
+
},
|
|
377
425
|
close() {
|
|
378
426
|
closing ??= (async () => {
|
|
427
|
+
// First: the Session runs its close listeners inside its own close call.
|
|
428
|
+
unsubscribeClose();
|
|
379
429
|
unsubscribe();
|
|
380
430
|
unsubscribeCommits();
|
|
381
431
|
conversation.dispose();
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
} from "@earendil-works/pi-durable";
|
|
12
13
|
|
|
13
14
|
async function answerText(api , answer , context ) {
|
|
@@ -48,7 +49,11 @@ export const Subagent = defineExtension({
|
|
|
48
49
|
throw new Error(`Subagent ${child} failed: ${settled.status}`);
|
|
49
50
|
}
|
|
50
51
|
const text = await answerText(api, settled.answer, context);
|
|
51
|
-
|
|
52
|
+
// `output` is what the model reads; spelled out so the result shape is checked, not just accepted.
|
|
53
|
+
return {
|
|
54
|
+
output: [{ type: "text", text }],
|
|
55
|
+
details: { conversationId: child },
|
|
56
|
+
} ;
|
|
52
57
|
},
|
|
53
58
|
}),
|
|
54
59
|
],
|