@junghanacs/entwurf 0.20.0 → 0.21.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 +85 -190
- package/BASELINE.md +6 -5
- package/CHANGELOG.md +220 -14
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +202 -60
- package/README.md +54 -26
- package/VERIFY.md +37 -7
- package/docs/acp-backend-rail.md +31 -15
- package/docs/external-mcp-host.md +58 -35
- package/docs/fresh-cut-policy.md +3 -2
- package/docs/setup-clean-host.md +110 -17
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +101 -109
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +6 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +326 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +50 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
- package/mcp/entwurf-bridge/src/index.ts +115 -111
- package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
- package/package.json +14 -5
- package/pi-extensions/entwurf-control.ts +71 -19
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +3 -3
- package/pi-extensions/lib/acp/event-mapper.ts +4 -4
- package/pi-extensions/lib/acp/tool-surface.ts +6 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +363 -0
- package/pi-extensions/lib/compaction-send-guard.ts +80 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
- package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
- package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
- package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
- package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
- package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
- package/pi-extensions/lib/meta-sender-identity.ts +305 -0
- package/pi-extensions/lib/mux-fresh-call.ts +64 -19
- package/pi-extensions/lib/native-push/adapter.ts +21 -24
- package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
- package/pi-extensions/lib/native-push/register.ts +7 -9
- package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
- package/run.sh +251 -35
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-agy-sender-identity.ts +1 -1
- package/scripts/check-codex-birth-hook.ts +264 -0
- package/scripts/check-codex-bridge-identity.ts +179 -0
- package/scripts/check-codex-native-push.ts +386 -0
- package/scripts/check-codex-sender-identity.ts +495 -0
- package/scripts/check-compaction-send-guard.ts +130 -0
- package/scripts/check-copilot-receive-arm.ts +4 -1
- package/scripts/check-entwurf-fact-provider.ts +38 -0
- package/scripts/check-entwurf-peers-surface.ts +13 -1
- package/scripts/check-entwurf-self-address.ts +15 -16
- package/scripts/check-entwurf-v2-contract.ts +4 -3
- package/scripts/check-entwurf-v2-decider.ts +7 -5
- package/scripts/check-entwurf-v2-native-push.ts +35 -7
- package/scripts/check-entwurf-v2-production.ts +203 -11
- package/scripts/check-entwurf-v2-runner.ts +1 -1
- package/scripts/check-entwurf-v2-surface.ts +1 -1
- package/scripts/check-gate-qualification.ts +7 -4
- package/scripts/check-harness-admission-parity.ts +0 -1
- package/scripts/check-install-surface.ts +23 -7
- package/scripts/check-mux-launch-tmux.ts +47 -2
- package/scripts/check-native-push-adapter.ts +20 -16
- package/scripts/check-native-push-register.ts +5 -1
- package/scripts/check-release-gate-outcomes.ts +47 -1
- package/scripts/check-setup-qualification.sh +3 -1
- package/scripts/codex-birth-doctor.sh +276 -0
- package/scripts/codex-birth-install.sh +414 -0
- package/scripts/codex-birth-uninstall.sh +170 -0
- package/scripts/codex-mcp-config.py +435 -0
- package/scripts/codex-statusline-config.py +434 -0
- package/scripts/codex_toml_io.py +532 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +113 -0
- package/scripts/lib/codex-fresh-source-receipts.ts +399 -0
- package/scripts/lib/launch-receipt-windows.ts +46 -0
- package/scripts/lib/tmux-coordinate-row.ts +58 -0
- package/scripts/meta-bridge-fresh-cut.ts +6 -1
- package/scripts/mutants/codex-native.json +838 -0
- package/scripts/mutants/compaction-send-guard.json +103 -0
- package/scripts/mutants/entwurf-peers.json +19 -0
- package/scripts/mutants/mux-fresh-call.json +19 -8
- package/scripts/mutants/omp-fresh.json +6 -4
- package/scripts/mutants/pi-package-ownership.json +26 -0
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/mutants/v2-surface.json +53 -1
- package/scripts/pi_settings_io.py +3 -1
- package/scripts/raw-async-delivery/README.md +2 -1
- package/scripts/raw-codex-measure/README.md +114 -46
- package/scripts/register-pi-package.py +38 -30
- package/scripts/register-pi-provider.py +3 -2
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-native-push-live.ts +3 -1
- package/scripts/smoke-codex-birth.sh +347 -0
- package/scripts/smoke-codex-config-state.sh +511 -0
- package/scripts/smoke-codex-fresh-live.ts +1186 -0
- package/scripts/smoke-codex-native-push-live.ts +75 -0
- package/scripts/smoke-setup-verdict.sh +123 -10
- package/scripts/smoke-user-scope-citizen.sh +199 -71
- package/scripts/tsconfig.json +1 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* native-push adapter rail — the transport LEAF by which a native-push backend
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* is (1) probed for a LIVE native target and (2) direct-injected with a message.
|
|
4
|
+
* Mirrors the ACP backend-adapter rail (acp/backend-adapter.ts §ADAPTERS
|
|
5
5
|
* /resolveAcpBackendAdapter): one interface, one registry, a fail-fast resolver.
|
|
6
6
|
*
|
|
7
7
|
* Purity contract (봉인 3):
|
|
@@ -26,6 +26,7 @@ import * as os from "node:os";
|
|
|
26
26
|
import * as path from "node:path";
|
|
27
27
|
|
|
28
28
|
import type { NativePushBackend } from "../entwurf-v2-contract.ts";
|
|
29
|
+
import { codexNativePushAdapter } from "./codex-ws-client.ts";
|
|
29
30
|
|
|
30
31
|
// ── runner seam (injectable process exec) ───────────────────────────────────
|
|
31
32
|
|
|
@@ -85,14 +86,13 @@ export const realNativePushRunner: NativePushRunner = {
|
|
|
85
86
|
// ── route + probe result ────────────────────────────────────────────────────
|
|
86
87
|
|
|
87
88
|
/**
|
|
88
|
-
* A VOLATILE native-push route
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* `send` call, handed straight from a fresh `probe`.
|
|
89
|
+
* A VOLATILE native-push route. Antigravity carries its live LS address; Codex carries
|
|
90
|
+
* the one app-server UDS that proved the target thread loaded. Routes are never stored:
|
|
91
|
+
* every dispatch derives one from a fresh probe and hands it straight to `send`.
|
|
92
92
|
*/
|
|
93
|
-
export
|
|
94
|
-
readonly lsAddress: string
|
|
95
|
-
}
|
|
93
|
+
export type NativePushRoute =
|
|
94
|
+
| { readonly backend: "antigravity"; readonly lsAddress: string }
|
|
95
|
+
| { readonly backend: "codex"; readonly socketPath: string };
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* A probe outcome. `alive` carries the volatile route; `dead`/`indeterminate` carry a
|
|
@@ -108,17 +108,12 @@ export type NativePushProbeResult =
|
|
|
108
108
|
export interface NativePushAdapter {
|
|
109
109
|
/** Backend discriminator (a member of NATIVE_PUSH_BACKENDS). */
|
|
110
110
|
readonly id: NativePushBackend;
|
|
111
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
*/
|
|
111
|
+
/** Whether the executor may re-probe and retry once after an ambiguous send failure.
|
|
112
|
+
* Codex is false: `codex queue` may accept before its receipt fails, so retry can duplicate. */
|
|
113
|
+
readonly retriable: boolean;
|
|
114
|
+
/** Probe the backend's current native route for `nativeSessionId`. */
|
|
116
115
|
probe(nativeSessionId: string): Promise<NativePushProbeResult>;
|
|
117
|
-
/**
|
|
118
|
-
* Direct-inject `content` into the conversation over `route`. Single attempt: throws
|
|
119
|
-
* on failure (fail-loud). Does NOT probe and does NOT retry — the executor hand owns
|
|
120
|
-
* re-probe/retry (봉인 3), so the adapter can never silently paper over a dead route.
|
|
121
|
-
*/
|
|
116
|
+
/** Direct-inject once through the just-probed route. Throws on failure. */
|
|
122
117
|
send(route: NativePushRoute, nativeSessionId: string, content: string): Promise<void>;
|
|
123
118
|
}
|
|
124
119
|
|
|
@@ -211,6 +206,7 @@ export function createAntigravityAdapter(deps: AntigravityAdapterDeps): NativePu
|
|
|
211
206
|
|
|
212
207
|
return {
|
|
213
208
|
id: "antigravity",
|
|
209
|
+
retriable: true,
|
|
214
210
|
|
|
215
211
|
async probe(nativeSessionId) {
|
|
216
212
|
let pids: number[];
|
|
@@ -237,7 +233,7 @@ export function createAntigravityAdapter(deps: AntigravityAdapterDeps): NativePu
|
|
|
237
233
|
for (const pid of pids) {
|
|
238
234
|
for (const lsAddress of portsByPid.get(pid) ?? []) {
|
|
239
235
|
if (await servesConversation(lsAddress, nativeSessionId)) {
|
|
240
|
-
return { status: "alive", route: { lsAddress } };
|
|
236
|
+
return { status: "alive", route: { backend: "antigravity", lsAddress } };
|
|
241
237
|
}
|
|
242
238
|
}
|
|
243
239
|
}
|
|
@@ -251,12 +247,13 @@ export function createAntigravityAdapter(deps: AntigravityAdapterDeps): NativePu
|
|
|
251
247
|
},
|
|
252
248
|
|
|
253
249
|
async send(route, nativeSessionId, content) {
|
|
250
|
+
if (route.backend !== "antigravity") {
|
|
251
|
+
throw new Error(`antigravity adapter received ${route.backend} route`);
|
|
252
|
+
}
|
|
254
253
|
const r = await runner.exec([binary, "agentapi", "send-message", nativeSessionId, content], {
|
|
255
254
|
env: { ANTIGRAVITY_LS_ADDRESS: route.lsAddress },
|
|
256
255
|
timeoutMs: AGY_SEND_TIMEOUT_MS,
|
|
257
256
|
});
|
|
258
|
-
// A non-zero code — including a timeout kill (124) on a stalled route — THROWS
|
|
259
|
-
// (fail-loud); the executor hand owns the 1-shot re-probe→re-send on that throw.
|
|
260
257
|
if (r.code !== 0) {
|
|
261
258
|
throw new Error(
|
|
262
259
|
`native-push send failed (agentapi send-message exit ${r.code}) via ${route.lsAddress}: ${
|
|
@@ -268,12 +265,12 @@ export function createAntigravityAdapter(deps: AntigravityAdapterDeps): NativePu
|
|
|
268
265
|
};
|
|
269
266
|
}
|
|
270
267
|
|
|
271
|
-
/**
|
|
268
|
+
/** Production adapters. */
|
|
272
269
|
export const antigravityAdapter: NativePushAdapter = createAntigravityAdapter({ runner: realNativePushRunner });
|
|
273
270
|
|
|
274
271
|
// ── registry + fail-fast resolver (mirror resolveAcpBackendAdapter) ──────────
|
|
275
272
|
|
|
276
|
-
const ADAPTERS: readonly NativePushAdapter[] = [antigravityAdapter];
|
|
273
|
+
const ADAPTERS: readonly NativePushAdapter[] = [antigravityAdapter, codexNativePushAdapter];
|
|
277
274
|
|
|
278
275
|
/**
|
|
279
276
|
* Resolve the native-push adapter that owns backend `id`. Fail-fast, like
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as net from "node:net";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
|
|
7
|
+
import type { NativePushBackend } from "../entwurf-v2-contract.js";
|
|
8
|
+
|
|
9
|
+
export interface CodexSocketEnv {
|
|
10
|
+
HOME?: string;
|
|
11
|
+
CODEX_HOME?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveCodexHome(env: CodexSocketEnv): string {
|
|
15
|
+
const explicit = env.CODEX_HOME?.trim();
|
|
16
|
+
if (explicit) return explicit;
|
|
17
|
+
const home = env.HOME?.trim();
|
|
18
|
+
if (!home) throw new Error("codex app-server: neither CODEX_HOME nor HOME is available");
|
|
19
|
+
return path.join(home, ".codex");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveCodexDefaultSocketPath(env: CodexSocketEnv): string {
|
|
23
|
+
return path.join(resolveCodexHome(env), "app-server-control", "app-server-control.sock");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type CodexSocketFileCheck = { ok: true } | { ok: false; status: "dead" | "indeterminate"; reason: string };
|
|
27
|
+
|
|
28
|
+
export function checkCodexSocketFile(socketPath: string): CodexSocketFileCheck {
|
|
29
|
+
try {
|
|
30
|
+
const stat = fs.lstatSync(socketPath);
|
|
31
|
+
if (stat.isSymbolicLink()) {
|
|
32
|
+
return { ok: false, status: "indeterminate", reason: `app-server socket is a symlink: ${socketPath}` };
|
|
33
|
+
}
|
|
34
|
+
if (!stat.isSocket()) {
|
|
35
|
+
return { ok: false, status: "indeterminate", reason: `app-server path is not a socket: ${socketPath}` };
|
|
36
|
+
}
|
|
37
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
status: "indeterminate",
|
|
41
|
+
reason: `app-server socket is owned by uid ${stat.uid}, not ${process.getuid()}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { ok: true };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
47
|
+
if (code === "ENOENT") return { ok: false, status: "dead", reason: `app-server socket is absent: ${socketPath}` };
|
|
48
|
+
return { ok: false, status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CodexRpcProtocol {
|
|
53
|
+
request(method: string, params: Record<string, unknown>): Promise<unknown>;
|
|
54
|
+
notify(method: string, params?: Record<string, unknown>): void;
|
|
55
|
+
close(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CodexProtocolOpener {
|
|
59
|
+
open(socketPath: string): Promise<CodexRpcProtocol>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function promiseResolvers<T>(): {
|
|
63
|
+
promise: Promise<T>;
|
|
64
|
+
resolve: (value: T | PromiseLike<T>) => void;
|
|
65
|
+
reject: (reason?: unknown) => void;
|
|
66
|
+
} {
|
|
67
|
+
return (
|
|
68
|
+
Promise as unknown as {
|
|
69
|
+
withResolvers<U>(): {
|
|
70
|
+
promise: Promise<U>;
|
|
71
|
+
resolve: (value: U | PromiseLike<U>) => void;
|
|
72
|
+
reject: (reason?: unknown) => void;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
).withResolvers<T>();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
79
|
+
const REQUEST_TIMEOUT_MS = 5_000;
|
|
80
|
+
const MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
81
|
+
|
|
82
|
+
function clientFrame(opcode: number, payload: Buffer): Buffer {
|
|
83
|
+
const mask = crypto.randomBytes(4);
|
|
84
|
+
let header: Buffer;
|
|
85
|
+
if (payload.length < 126) {
|
|
86
|
+
header = Buffer.from([0x80 | opcode, 0x80 | payload.length]);
|
|
87
|
+
} else if (payload.length <= 0xffff) {
|
|
88
|
+
header = Buffer.allocUnsafe(4);
|
|
89
|
+
header[0] = 0x80 | opcode;
|
|
90
|
+
header[1] = 0x80 | 126;
|
|
91
|
+
header.writeUInt16BE(payload.length, 2);
|
|
92
|
+
} else {
|
|
93
|
+
header = Buffer.allocUnsafe(10);
|
|
94
|
+
header[0] = 0x80 | opcode;
|
|
95
|
+
header[1] = 0x80 | 127;
|
|
96
|
+
header.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
97
|
+
}
|
|
98
|
+
const masked = Buffer.allocUnsafe(payload.length);
|
|
99
|
+
for (let index = 0; index < payload.length; index++) masked[index] = payload[index]! ^ mask[index % 4]!;
|
|
100
|
+
return Buffer.concat([header, mask, masked]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class CodexWebSocketRpc implements CodexRpcProtocol {
|
|
104
|
+
private nextId = 1;
|
|
105
|
+
private buffer = Buffer.alloc(0);
|
|
106
|
+
private fragmentOpcode: number | null = null;
|
|
107
|
+
private fragments: Buffer[] = [];
|
|
108
|
+
private fragmentBytes = 0;
|
|
109
|
+
private terminalError: Error | null = null;
|
|
110
|
+
private readonly pending = new Map<
|
|
111
|
+
number,
|
|
112
|
+
{ resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }
|
|
113
|
+
>();
|
|
114
|
+
|
|
115
|
+
private readonly socket: net.Socket;
|
|
116
|
+
|
|
117
|
+
private constructor(socket: net.Socket) {
|
|
118
|
+
this.socket = socket;
|
|
119
|
+
socket.on("data", (chunk) => this.feed(chunk));
|
|
120
|
+
socket.on("error", (error) => this.failAll(error));
|
|
121
|
+
socket.on("close", () => this.failAll(new Error("codex app-server WebSocket closed")));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
static open(socketPath: string): Promise<CodexWebSocketRpc> {
|
|
125
|
+
const { promise, resolve, reject } = promiseResolvers<CodexWebSocketRpc>();
|
|
126
|
+
const socket = net.createConnection(socketPath);
|
|
127
|
+
const key = crypto.randomBytes(16).toString("base64");
|
|
128
|
+
const expectedAccept = crypto
|
|
129
|
+
.createHash("sha1")
|
|
130
|
+
.update(key + WS_GUID)
|
|
131
|
+
.digest("base64");
|
|
132
|
+
let header = Buffer.alloc(0);
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
const error = Object.assign(new Error("codex app-server WebSocket handshake timed out"), { code: "ETIMEDOUT" });
|
|
135
|
+
socket.destroy(error);
|
|
136
|
+
}, REQUEST_TIMEOUT_MS);
|
|
137
|
+
const onError = (error: Error): void => {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
reject(error);
|
|
140
|
+
};
|
|
141
|
+
const onData = (chunk: Buffer): void => {
|
|
142
|
+
header = Buffer.concat([header, chunk]);
|
|
143
|
+
if (header.length > 16 * 1024) {
|
|
144
|
+
socket.destroy(new Error("codex app-server WebSocket handshake exceeded 16 KiB"));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const boundary = header.indexOf("\r\n\r\n");
|
|
148
|
+
if (boundary < 0) return;
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
const lines = header.subarray(0, boundary).toString("utf8").split("\r\n");
|
|
151
|
+
const headers = new Map<string, string>();
|
|
152
|
+
for (const line of lines.slice(1)) {
|
|
153
|
+
const colon = line.indexOf(":");
|
|
154
|
+
if (colon > 0) headers.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim());
|
|
155
|
+
}
|
|
156
|
+
if (lines[0] !== "HTTP/1.1 101 Switching Protocols" || headers.get("sec-websocket-accept") !== expectedAccept) {
|
|
157
|
+
socket.destroy(new Error("codex app-server refused the WebSocket upgrade"));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
socket.off("error", onError);
|
|
161
|
+
socket.off("data", onData);
|
|
162
|
+
const rpc = new CodexWebSocketRpc(socket);
|
|
163
|
+
const remainder = header.subarray(boundary + 4);
|
|
164
|
+
if (remainder.length > 0) rpc.feed(remainder);
|
|
165
|
+
resolve(rpc);
|
|
166
|
+
};
|
|
167
|
+
socket.once("error", onError);
|
|
168
|
+
socket.on("data", onData);
|
|
169
|
+
socket.once("connect", () => {
|
|
170
|
+
socket.write(
|
|
171
|
+
`GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
return promise;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
request(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
178
|
+
if (this.terminalError) return Promise.reject(this.terminalError);
|
|
179
|
+
const id = this.nextId++;
|
|
180
|
+
const { promise, resolve, reject } = promiseResolvers<unknown>();
|
|
181
|
+
const timer = setTimeout(() => {
|
|
182
|
+
this.pending.delete(id);
|
|
183
|
+
reject(new Error(`codex app-server request timed out: ${method}`));
|
|
184
|
+
}, REQUEST_TIMEOUT_MS);
|
|
185
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
186
|
+
this.socket.write(clientFrame(0x1, Buffer.from(JSON.stringify({ id, method, params }))));
|
|
187
|
+
return promise;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
notify(method: string, params: Record<string, unknown> = {}): void {
|
|
191
|
+
if (this.terminalError) return;
|
|
192
|
+
this.socket.write(clientFrame(0x1, Buffer.from(JSON.stringify({ method, params }))));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
close(): void {
|
|
196
|
+
if (!this.socket.destroyed) this.socket.destroy();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private feed(chunk: Buffer): void {
|
|
200
|
+
if (this.terminalError) return;
|
|
201
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
202
|
+
while (!this.terminalError && this.takeFrame()) {
|
|
203
|
+
// Drain every complete frame already buffered.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private takeFrame(): boolean {
|
|
208
|
+
if (this.buffer.length < 2) return false;
|
|
209
|
+
const first = this.buffer[0]!;
|
|
210
|
+
const second = this.buffer[1]!;
|
|
211
|
+
if ((second & 0x80) !== 0) return this.protocolFailure("server sent a masked WebSocket frame");
|
|
212
|
+
let length = second & 0x7f;
|
|
213
|
+
let offset = 2;
|
|
214
|
+
if (length === 126) {
|
|
215
|
+
if (this.buffer.length < 4) return false;
|
|
216
|
+
length = this.buffer.readUInt16BE(2);
|
|
217
|
+
offset = 4;
|
|
218
|
+
} else if (length === 127) {
|
|
219
|
+
if (this.buffer.length < 10) return false;
|
|
220
|
+
const wide = this.buffer.readBigUInt64BE(2);
|
|
221
|
+
if (wide > BigInt(MAX_MESSAGE_BYTES)) return this.protocolFailure("WebSocket frame exceeds 1 MiB");
|
|
222
|
+
length = Number(wide);
|
|
223
|
+
offset = 10;
|
|
224
|
+
}
|
|
225
|
+
if (length > MAX_MESSAGE_BYTES) return this.protocolFailure("WebSocket frame exceeds 1 MiB");
|
|
226
|
+
if (this.buffer.length < offset + length) return false;
|
|
227
|
+
const payload = this.buffer.subarray(offset, offset + length);
|
|
228
|
+
this.buffer = this.buffer.subarray(offset + length);
|
|
229
|
+
const opcode = first & 0x0f;
|
|
230
|
+
const final = (first & 0x80) !== 0;
|
|
231
|
+
if (opcode === 0x9) {
|
|
232
|
+
this.socket.write(clientFrame(0xa, payload));
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
if (opcode === 0x8) return this.protocolFailure("codex app-server closed the WebSocket");
|
|
236
|
+
if (opcode === 0x1 && final) return this.receiveJson(payload);
|
|
237
|
+
if (opcode === 0x1 && !final && this.fragmentOpcode === null) {
|
|
238
|
+
this.fragmentOpcode = opcode;
|
|
239
|
+
this.fragments = [payload];
|
|
240
|
+
this.fragmentBytes = payload.length;
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
if (opcode === 0x0 && this.fragmentOpcode === 0x1) {
|
|
244
|
+
this.fragmentBytes += payload.length;
|
|
245
|
+
if (this.fragmentBytes > MAX_MESSAGE_BYTES) return this.protocolFailure("fragmented message exceeds 1 MiB");
|
|
246
|
+
this.fragments.push(payload);
|
|
247
|
+
if (final) {
|
|
248
|
+
const message = Buffer.concat(this.fragments, this.fragmentBytes);
|
|
249
|
+
this.fragmentOpcode = null;
|
|
250
|
+
this.fragments = [];
|
|
251
|
+
this.fragmentBytes = 0;
|
|
252
|
+
return this.receiveJson(message);
|
|
253
|
+
}
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
return this.protocolFailure(`unsupported WebSocket opcode ${opcode}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private receiveJson(payload: Buffer): boolean {
|
|
260
|
+
let parsed: unknown;
|
|
261
|
+
try {
|
|
262
|
+
parsed = JSON.parse(payload.toString("utf8"));
|
|
263
|
+
} catch {
|
|
264
|
+
return this.protocolFailure("codex app-server returned invalid JSON");
|
|
265
|
+
}
|
|
266
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
267
|
+
return this.protocolFailure("codex app-server returned a non-object JSON message");
|
|
268
|
+
const message = parsed as { id?: unknown; result?: unknown; error?: unknown };
|
|
269
|
+
if (typeof message.id !== "number") return true;
|
|
270
|
+
const entry = this.pending.get(message.id);
|
|
271
|
+
if (!entry) return true;
|
|
272
|
+
clearTimeout(entry.timer);
|
|
273
|
+
this.pending.delete(message.id);
|
|
274
|
+
if (message.error !== undefined)
|
|
275
|
+
entry.reject(new Error(`codex app-server error: ${JSON.stringify(message.error)}`));
|
|
276
|
+
else entry.resolve(message.result);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private protocolFailure(message: string): false {
|
|
281
|
+
if (this.terminalError) return false;
|
|
282
|
+
const error = new Error(message);
|
|
283
|
+
this.terminalError = error;
|
|
284
|
+
this.buffer = Buffer.alloc(0);
|
|
285
|
+
this.fragmentOpcode = null;
|
|
286
|
+
this.fragments = [];
|
|
287
|
+
this.fragmentBytes = 0;
|
|
288
|
+
this.failAll(error);
|
|
289
|
+
if (!this.socket.destroyed) this.socket.destroy(error);
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private failAll(error: Error): void {
|
|
294
|
+
for (const entry of this.pending.values()) {
|
|
295
|
+
clearTimeout(entry.timer);
|
|
296
|
+
entry.reject(error);
|
|
297
|
+
}
|
|
298
|
+
this.pending.clear();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export const realCodexProtocolOpener: CodexProtocolOpener = {
|
|
303
|
+
open: (socketPath) => CodexWebSocketRpc.open(socketPath),
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const CODEX_CLIENT_INFO = { name: "entwurf-native-push/codex", title: "entwurf-native-push/codex", version: "0" };
|
|
307
|
+
|
|
308
|
+
async function initialize(protocol: CodexRpcProtocol): Promise<void> {
|
|
309
|
+
await protocol.request("initialize", {
|
|
310
|
+
clientInfo: CODEX_CLIENT_INFO,
|
|
311
|
+
capabilities: { experimentalApi: true, requestAttestation: false },
|
|
312
|
+
});
|
|
313
|
+
protocol.notify("initialized");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface CodexRoute {
|
|
317
|
+
readonly backend: "codex";
|
|
318
|
+
readonly socketPath: string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export type CodexProbeResult =
|
|
322
|
+
| { status: "alive"; route: CodexRoute }
|
|
323
|
+
| { status: "dead" | "indeterminate"; reason: string };
|
|
324
|
+
|
|
325
|
+
export interface CodexExecResult {
|
|
326
|
+
code: number;
|
|
327
|
+
stdout: string;
|
|
328
|
+
stderr: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export interface CodexExecRunner {
|
|
332
|
+
exec(argv: readonly string[], timeoutMs: number): Promise<CodexExecResult>;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const realCodexExecRunner: CodexExecRunner = {
|
|
336
|
+
exec(argv, timeoutMs) {
|
|
337
|
+
const { promise, resolve } = promiseResolvers<CodexExecResult>();
|
|
338
|
+
const [command, ...args] = argv;
|
|
339
|
+
execFile(command ?? "", args, { maxBuffer: MAX_MESSAGE_BYTES, timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
340
|
+
const failure = error as (NodeJS.ErrnoException & { killed?: boolean }) | null;
|
|
341
|
+
resolve({
|
|
342
|
+
code: failure == null ? 0 : failure.killed ? 124 : typeof failure.code === "number" ? failure.code : 127,
|
|
343
|
+
stdout: stdout ?? "",
|
|
344
|
+
stderr: stderr ?? "",
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
return promise;
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
export interface CodexAdapterDeps {
|
|
352
|
+
env?: CodexSocketEnv;
|
|
353
|
+
openProtocol?: CodexProtocolOpener;
|
|
354
|
+
checkSocket?: (socketPath: string) => CodexSocketFileCheck;
|
|
355
|
+
runner?: CodexExecRunner;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export async function probeCodexThread(
|
|
359
|
+
nativeSessionId: string,
|
|
360
|
+
deps: CodexAdapterDeps = {},
|
|
361
|
+
): Promise<CodexProbeResult> {
|
|
362
|
+
const socketPath = resolveCodexDefaultSocketPath(deps.env ?? process.env);
|
|
363
|
+
const socketCheck = (deps.checkSocket ?? checkCodexSocketFile)(socketPath);
|
|
364
|
+
if (!socketCheck.ok) return { status: socketCheck.status, reason: socketCheck.reason };
|
|
365
|
+
let protocol: CodexRpcProtocol;
|
|
366
|
+
try {
|
|
367
|
+
protocol = await (deps.openProtocol ?? realCodexProtocolOpener).open(socketPath);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
370
|
+
return {
|
|
371
|
+
status: code === "ENOENT" || code === "ECONNREFUSED" ? "dead" : "indeterminate",
|
|
372
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
await initialize(protocol);
|
|
377
|
+
let cursor: string | undefined;
|
|
378
|
+
for (let page = 0; page < 64; page++) {
|
|
379
|
+
const result = (await protocol.request("thread/loaded/list", cursor ? { cursor } : {})) as {
|
|
380
|
+
data?: unknown;
|
|
381
|
+
nextCursor?: unknown;
|
|
382
|
+
};
|
|
383
|
+
if (!Array.isArray(result?.data) || !result.data.every((item) => typeof item === "string")) {
|
|
384
|
+
throw new Error("codex app-server returned an invalid thread/loaded/list page");
|
|
385
|
+
}
|
|
386
|
+
if (result.data.includes(nativeSessionId)) return { status: "alive", route: { backend: "codex", socketPath } };
|
|
387
|
+
if (result.nextCursor == null)
|
|
388
|
+
return { status: "dead", reason: `Codex app-server has no loaded thread ${nativeSessionId}` };
|
|
389
|
+
if (typeof result.nextCursor !== "string" || result.nextCursor === cursor) {
|
|
390
|
+
throw new Error("codex app-server returned a non-advancing thread cursor");
|
|
391
|
+
}
|
|
392
|
+
cursor = result.nextCursor;
|
|
393
|
+
}
|
|
394
|
+
return { status: "indeterminate", reason: "Codex loaded-thread inventory exceeded 64 pages" };
|
|
395
|
+
} catch (error) {
|
|
396
|
+
return { status: "indeterminate", reason: error instanceof Error ? error.message : String(error) };
|
|
397
|
+
} finally {
|
|
398
|
+
protocol.close();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const QUEUE_TIMEOUT_MS = 8_000;
|
|
403
|
+
const QUEUE_ACK = /^Queued message (\S+) for thread (\S+)\.$/;
|
|
404
|
+
|
|
405
|
+
export async function sendViaCodexQueue(
|
|
406
|
+
route: CodexRoute,
|
|
407
|
+
nativeSessionId: string,
|
|
408
|
+
content: string,
|
|
409
|
+
runner: CodexExecRunner = realCodexExecRunner,
|
|
410
|
+
): Promise<void> {
|
|
411
|
+
const result = await runner.exec(
|
|
412
|
+
["codex", "queue", "--remote", `unix://${route.socketPath}`, "--thread", nativeSessionId, "--message", content],
|
|
413
|
+
QUEUE_TIMEOUT_MS,
|
|
414
|
+
);
|
|
415
|
+
if (result.code !== 0) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`codex queue exited ${result.code}: ${result.stderr.trim() || result.stdout.trim() || "no output"}`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
const match = QUEUE_ACK.exec(result.stdout.trim());
|
|
421
|
+
if (!match || match[2] !== nativeSessionId) {
|
|
422
|
+
throw new Error(`codex queue returned an invalid acknowledgement: ${JSON.stringify(result.stdout.trim())}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function setCodexThreadName(
|
|
427
|
+
socketPath: string,
|
|
428
|
+
threadId: string,
|
|
429
|
+
name: string,
|
|
430
|
+
opener: CodexProtocolOpener = realCodexProtocolOpener,
|
|
431
|
+
): Promise<void> {
|
|
432
|
+
const protocol = await opener.open(socketPath);
|
|
433
|
+
try {
|
|
434
|
+
await initialize(protocol);
|
|
435
|
+
const result = await protocol.request("thread/name/set", { threadId, name });
|
|
436
|
+
if (result == null || typeof result !== "object" || Array.isArray(result) || Object.keys(result).length !== 0) {
|
|
437
|
+
throw new Error(
|
|
438
|
+
`codex app-server returned an invalid thread/name/set acknowledgement: ${JSON.stringify(result)}`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
} finally {
|
|
442
|
+
protocol.close();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export interface CodexThreadItem {
|
|
447
|
+
type?: string;
|
|
448
|
+
tool?: string;
|
|
449
|
+
server?: string;
|
|
450
|
+
result?: { content?: unknown[] } | null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export interface CodexThreadView {
|
|
454
|
+
id?: string;
|
|
455
|
+
name?: string | null;
|
|
456
|
+
turns?: Array<{ items?: CodexThreadItem[] }>;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Read one thread's app-server view. `[source]` rust-v0.153.4
|
|
461
|
+
* `app-server-protocol/src/protocol/v2/thread.rs:1656-1671` — params `{ threadId, includeTurns? }`,
|
|
462
|
+
* response `{ thread: Thread }`; `thread_data.rs:203-281` — `name: Option<String>` is the visible
|
|
463
|
+
* title this unit arms at birth, and `turns` is populated ONLY when `includeTurns` is true.
|
|
464
|
+
*
|
|
465
|
+
* This is a READ, so it is not part of the delivery rail: it carries no retry and no liveness
|
|
466
|
+
* meaning. `probeCodexThread` remains the only reachability answer.
|
|
467
|
+
*/
|
|
468
|
+
export async function readCodexThread(
|
|
469
|
+
socketPath: string,
|
|
470
|
+
threadId: string,
|
|
471
|
+
includeTurns = false,
|
|
472
|
+
opener: CodexProtocolOpener = realCodexProtocolOpener,
|
|
473
|
+
): Promise<CodexThreadView> {
|
|
474
|
+
const protocol = await opener.open(socketPath);
|
|
475
|
+
try {
|
|
476
|
+
await initialize(protocol);
|
|
477
|
+
const result = (await protocol.request("thread/read", { threadId, includeTurns })) as {
|
|
478
|
+
thread?: unknown;
|
|
479
|
+
} | null;
|
|
480
|
+
const thread = result?.thread;
|
|
481
|
+
if (thread == null || typeof thread !== "object" || Array.isArray(thread)) {
|
|
482
|
+
throw new Error(`codex app-server returned an invalid thread/read response: ${JSON.stringify(result)}`);
|
|
483
|
+
}
|
|
484
|
+
return thread as CodexThreadView;
|
|
485
|
+
} finally {
|
|
486
|
+
protocol.close();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export interface CodexNativePushAdapter {
|
|
491
|
+
readonly id: NativePushBackend;
|
|
492
|
+
readonly retriable: false;
|
|
493
|
+
probe(nativeSessionId: string): Promise<CodexProbeResult>;
|
|
494
|
+
send(route: CodexRoute, nativeSessionId: string, content: string): Promise<void>;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function createCodexNativePushAdapter(deps: CodexAdapterDeps = {}): CodexNativePushAdapter {
|
|
498
|
+
return {
|
|
499
|
+
id: "codex",
|
|
500
|
+
retriable: false,
|
|
501
|
+
probe: (nativeSessionId) => probeCodexThread(nativeSessionId, deps),
|
|
502
|
+
send: (route, nativeSessionId, content) => sendViaCodexQueue(route, nativeSessionId, content, deps.runner),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export const codexNativePushAdapter = createCodexNativePushAdapter();
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* native-push/register — the pure-ish core of the `entwurf_register_native` MCP tool
|
|
3
|
-
* (봉인 5). It REGISTERS an already-running
|
|
4
|
-
* citizen; it does NOT spawn one
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* (봉인 5). It REGISTERS an already-running Antigravity conversation as a garden
|
|
4
|
+
* citizen; it does NOT spawn one. Codex owns automatic birth from its system hook,
|
|
5
|
+
* so widening the native-push backend registry does not widen this manual registration
|
|
6
|
+
* authority.
|
|
7
7
|
*
|
|
8
8
|
* Flow (봉인 5):
|
|
9
9
|
* 1. Resolve the native-push adapter for the backend and PROBE the conversation. Only a
|
|
@@ -23,15 +23,13 @@
|
|
|
23
23
|
* upsert (pi-free), so the harness-neutral MCP bridge can reach it at boot.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type { NativePushBackend } from "../entwurf-v2-contract.ts";
|
|
27
26
|
import { type MetaCitizenBackend, type UpsertAction, upsertMetaSession } from "../meta-session.ts";
|
|
28
27
|
import { type NativePushAdapter, resolveNativePushAdapter } from "./adapter.ts";
|
|
29
28
|
|
|
30
29
|
export interface RegisterNativeConversationInput {
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
/** The backend's native conversation id (antigravity conversationId). */
|
|
30
|
+
/** Codex is born automatically from its system hook; this manual lane stays Antigravity-only. */
|
|
31
|
+
backend: "antigravity";
|
|
32
|
+
/** The Antigravity conversation id. */
|
|
35
33
|
nativeSessionId: string;
|
|
36
34
|
/** The cwd to record for this citizen — REQUIRED: a native conversation's metadata cannot
|
|
37
35
|
* confirm it, so the caller must state it (봉인 5). */
|