@junghanacs/entwurf 0.20.1 → 0.22.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 -3
- package/CHANGELOG.md +473 -14
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +332 -60
- package/README.md +100 -22
- package/VERIFY.md +93 -7
- package/docs/acp-backend-rail.md +0 -1
- package/docs/external-mcp-host.md +64 -33
- package/docs/setup-clean-host.md +151 -17
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +130 -114
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +519 -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-runner.js +3 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
- 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 +189 -19
- 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/codex-socket-path.js +30 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
- package/mcp/entwurf-bridge/src/index.ts +154 -116
- package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
- package/package.json +13 -4
- package/pi-extensions/entwurf-control.ts +71 -19
- package/pi-extensions/lib/codex-caller-seat.ts +204 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +580 -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-runner.ts +3 -2
- package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
- 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 +233 -29
- 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 +342 -28
- package/scripts/check-agy-sender-identity.ts +1 -1
- package/scripts/check-codex-app-server-launch.ts +445 -0
- 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 +245 -12
- package/scripts/check-entwurf-v2-runner.ts +1 -1
- package/scripts/check-entwurf-v2-send.ts +26 -7
- package/scripts/check-entwurf-v2-surface.ts +1 -1
- package/scripts/check-gate-qualification.ts +8 -3
- package/scripts/check-harness-admission-parity.ts +0 -1
- package/scripts/check-mux-launch-tmux.ts +345 -4
- 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-app-server-launch.sh +275 -0
- 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-socket-path.ts +33 -0
- package/scripts/codex-statusline-config.py +434 -0
- package/scripts/codex-terminal-title-config.py +500 -0
- package/scripts/codex_toml_io.py +653 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +121 -0
- package/scripts/lib/codex-fresh-source-receipts.ts +426 -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-app-server-launch.json +157 -0
- package/scripts/mutants/codex-caller-seat.json +336 -0
- 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 +93 -10
- package/scripts/mutants/omp-fresh.json +6 -4
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/mutants/v2-surface.json +75 -1
- package/scripts/raw-async-delivery/README.md +2 -1
- package/scripts/raw-codex-measure/README.md +114 -46
- 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 +700 -0
- package/scripts/smoke-codex-fresh-live.ts +1426 -0
- package/scripts/smoke-codex-native-push-live.ts +75 -0
- package/scripts/smoke-entwurf-chain-live.ts +50 -0
- package/scripts/smoke-setup-verdict.sh +125 -10
- package/scripts/tsconfig.json +1 -0
|
@@ -64,16 +64,17 @@ import * as process from "node:process";
|
|
|
64
64
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
65
65
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
66
66
|
import { z } from "zod";
|
|
67
|
+
import { codexCallerFreshPreflight, codexFreshPreflight } from "../../../pi-extensions/lib/codex-fresh-preflight.js";
|
|
67
68
|
import { controlSocketPathIn, defaultControlSocketDir } from "../../../pi-extensions/lib/control-socket-path.js";
|
|
68
69
|
import { resolveMailboxReceiverFacts } from "../../../pi-extensions/lib/entwurf-deliverability.js";
|
|
69
70
|
import { listEntwurfFacts } from "../../../pi-extensions/lib/entwurf-fact-provider.js";
|
|
70
|
-
import { renderEntwurfPeers } from "../../../pi-extensions/lib/entwurf-peers-render.js";
|
|
71
|
+
import { ENTWURF_PEERS_RENDER_LIMIT, renderEntwurfPeers } from "../../../pi-extensions/lib/entwurf-peers-render.js";
|
|
71
72
|
import { computeSelfAddressability } from "../../../pi-extensions/lib/entwurf-self-address.js";
|
|
72
73
|
import { nativePushSupported } from "../../../pi-extensions/lib/entwurf-v2-contract.js";
|
|
73
74
|
import { resolveMailboxWakeModeCapability } from "../../../pi-extensions/lib/entwurf-v2-decider.js";
|
|
74
75
|
import { runAndRenderEntwurfV2FromSurface } from "../../../pi-extensions/lib/entwurf-v2-surface.js";
|
|
75
76
|
import { makeVisibleResumeDeps, renderVisibleResume, visibleResume, } from "../../../pi-extensions/lib/entwurf-v2-visible-resume.js";
|
|
76
|
-
import { probeNativeSenderAlive, resolveTrustedMetaSenderIdentity, } from "../../../pi-extensions/lib/meta-sender-identity.js";
|
|
77
|
+
import { probeNativeSenderAlive, reconcileSenderIdentityClaims, resolveCodexRequestSenderIdentity, resolveTrustedMetaSenderIdentity, } from "../../../pi-extensions/lib/meta-sender-identity.js";
|
|
77
78
|
import { applyOmpBridgeChildRootPolicy, defaultMetaMailboxDir, defaultMetaSessionsDir, makeStoreRecordReader, readActiveStoreEntries, readMetaInbox, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "../../../pi-extensions/lib/meta-session.js";
|
|
78
79
|
import { freshCall, renderFreshCall } from "../../../pi-extensions/lib/mux-fresh-call.js";
|
|
79
80
|
import { RESUME_CALL_REJECT_HINT, resumeCall } from "../../../pi-extensions/lib/mux-resume-call.js";
|
|
@@ -128,11 +129,11 @@ function textErr(msg) {
|
|
|
128
129
|
const server = new McpServer({ name: "entwurf-bridge", version: "0.1.0" });
|
|
129
130
|
// Transparency envelope.
|
|
130
131
|
//
|
|
131
|
-
// Record-backed pi and
|
|
132
|
-
// so the receiver renders WHO (agentId, sessionId), FROM WHERE (cwd), and
|
|
133
|
-
// (timestamp UTC, displayed in KST). `entwurf_self` is identity-required: pi's env
|
|
134
|
-
//
|
|
135
|
-
//
|
|
132
|
+
// Record-backed pi, pid-marker native senders, and request-scoped Codex senders carry a
|
|
133
|
+
// structured envelope so the receiver renders WHO (agentId, sessionId), FROM WHERE (cwd), and
|
|
134
|
+
// WHEN (timestamp UTC, displayed in KST). `entwurf_self` is identity-required: pi's env is a
|
|
135
|
+
// child carrier for the garden id established by record birth; a native sender marker or Codex
|
|
136
|
+
// request selector is accepted only through its backing record. Plain anonymous external hosts
|
|
136
137
|
// fail. #50 C4: v2 delivery is identity-REQUIRED by default — "if we don't know
|
|
137
138
|
// who sent it, we don't send it" holds on every install surface, not only where
|
|
138
139
|
// an installer remembered to set a flag. The ONE documented escape hatch is
|
|
@@ -144,27 +145,25 @@ const server = new McpServer({ name: "entwurf-bridge", version: "0.1.0" });
|
|
|
144
145
|
class EntwurfEnvelopeWiringError extends Error {
|
|
145
146
|
constructor(missing) {
|
|
146
147
|
super(`entwurf sender envelope wiring incomplete — missing env: ${missing.join(", ")}, ` +
|
|
147
|
-
"and no
|
|
148
|
-
"PI_SESSION_ID + PI_AGENT_ID
|
|
149
|
-
"
|
|
150
|
-
"
|
|
151
|
-
"
|
|
148
|
+
"and no record-backed native sender claim was found. This MCP child should inherit " +
|
|
149
|
+
"PI_SESSION_ID + PI_AGENT_ID from an entwurf-control pi session, carry a live sender marker " +
|
|
150
|
+
"written by its native hook, or be the managed Codex MCP child whose current tool request " +
|
|
151
|
+
"names a thread backed by a Codex citizen record. entwurf_self is callable only while one of " +
|
|
152
|
+
"those authoritative identity paths is present.");
|
|
152
153
|
}
|
|
153
154
|
}
|
|
154
|
-
// #50 C4: anonymous sends are refused BY DEFAULT — a send with no pi-session
|
|
155
|
-
//
|
|
156
|
-
//
|
|
155
|
+
// #50 C4: anonymous sends are refused BY DEFAULT — a send with no pi-session identity,
|
|
156
|
+
// trusted pid marker, or request-scoped Codex record does not go out as anonymous external-mcp
|
|
157
|
+
// unless the operator explicitly wired the escape hatch.
|
|
157
158
|
// "If we don't know who sent it, we don't send it."
|
|
158
159
|
class EntwurfSenderIdentityError extends Error {
|
|
159
160
|
constructor() {
|
|
160
161
|
super("entwurf-bridge refused: no authoritative sender identity. Anonymous external sends are " +
|
|
161
|
-
"refused by default, and no pi-session env
|
|
162
|
-
"marker
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
"ENTWURF_BRIDGE_ALLOW_ANONYMOUS_SENDER=1 (explicit operator wiring; the send is then marked " +
|
|
167
|
-
"external/non-replyable).");
|
|
162
|
+
"refused by default, and this request has no complete pi-session env, live record-backed " +
|
|
163
|
+
"sender marker, or managed Codex thread selector backed by a Codex citizen record. Open the " +
|
|
164
|
+
"session through its installed birth/MCP integration, then retry. A deliberately-anonymous " +
|
|
165
|
+
"external MCP host may set ENTWURF_BRIDGE_ALLOW_ANONYMOUS_SENDER=1 (explicit operator wiring; " +
|
|
166
|
+
"the send is then marked external/non-replyable).");
|
|
168
167
|
}
|
|
169
168
|
}
|
|
170
169
|
function buildStrictPiSenderEnvelope() {
|
|
@@ -203,41 +202,7 @@ function buildStrictPiSenderEnvelope() {
|
|
|
203
202
|
replyable: self.replyable,
|
|
204
203
|
};
|
|
205
204
|
}
|
|
206
|
-
async function
|
|
207
|
-
// No pi-session identity. Try the meta-sender marker: a native backend that minted a
|
|
208
|
-
// garden-id from its own hook (Claude SessionStart / agy PreInvocation). The marker is
|
|
209
|
-
// keyed by the shared parent pid — this MCP child's process.ppid IS the native host the
|
|
210
|
-
// hook ran under (NOT cwd inference). A trusted marker promotes this process from
|
|
211
|
-
// anonymous external-mcp to a meta-session sender addressed by its garden-id.
|
|
212
|
-
const trusted = resolveTrustedMetaSenderIdentity({
|
|
213
|
-
markerPath: process.env.ENTWURF_META_SENDER_MARKER?.trim() || undefined,
|
|
214
|
-
});
|
|
215
|
-
if (!trusted)
|
|
216
|
-
return null;
|
|
217
|
-
const { marker, identity } = trusted;
|
|
218
|
-
// Identity is trusted — but `replyable` is a SEPARATE fact, and WHICH fact depends on the
|
|
219
|
-
// rail a reply would ride (보정①). THREE values, not a native-push-or-self-fetch binary:
|
|
220
|
-
// native-push ← nativePushSupported(backend). NOT wakeMode: `direct-inject` also covers
|
|
221
|
-
// codex/pi, which have no native-push adapter.
|
|
222
|
-
// self-fetch ← resolveMailboxWakeModeCapability (the decider's mailbox seam — one owner
|
|
223
|
-
// with dispatch). A new hardcoded backend list would drift the moment the registry
|
|
224
|
-
// admits another self-fetch citizen.
|
|
225
|
-
// none ← neither. omp today: no mailbox drain, no native-push adapter. Rendering
|
|
226
|
-
// this as self-fetch printed a mailboxPath nothing drains.
|
|
227
|
-
// self-fetch (claude-code/copilot): can this citizen's own inbox wake? → the SHARED
|
|
228
|
-
// receiver composition `resolveMailboxReceiverFacts`, the same one the v2 dispatch seam
|
|
229
|
-
// uses, so a citizen's self-reported replyability can never disagree with what dispatch
|
|
230
|
-
// decides about it. It reads the presence marker (a dead/reused owner already folds to
|
|
231
|
-
// null) AND, where the watch owner is the sender-marker process, the #101 join that says
|
|
232
|
-
// the owner is still serving THIS garden rather than one it switched away from.
|
|
233
|
-
// native-push (antigravity): there is no inbox and no watch. A reply is injected into a
|
|
234
|
-
// live app-server conversation, so only an adapter probe can answer. Composing the
|
|
235
|
-
// receiver atom here would demand `watchArmed` from a backend that never arms one, and
|
|
236
|
-
// every agy citizen would report replyable:false forever.
|
|
237
|
-
// Either way an inactive/unreachable citizen STILL returns its identity (who-sent must
|
|
238
|
-
// survive; degrading to null would erase the sender) — only with replyable:false.
|
|
239
|
-
// The rail, named ONCE and reused for both the predicate and the caller's rendering —
|
|
240
|
-
// so entwurf_self can never re-derive it differently from what decided `replyable`.
|
|
205
|
+
async function buildMetaSenderEnvelope(identity, cwd, trustedMarker) {
|
|
241
206
|
const metaDeliveryDomain = nativePushSupported(identity.backend)
|
|
242
207
|
? "native-push"
|
|
243
208
|
: resolveMailboxWakeModeCapability(identity)
|
|
@@ -252,6 +217,9 @@ async function buildTrustedMetaSenderEnvelope(cwd = process.cwd()) {
|
|
|
252
217
|
}
|
|
253
218
|
: metaDeliveryDomain === "self-fetch"
|
|
254
219
|
? (() => {
|
|
220
|
+
if (!trustedMarker) {
|
|
221
|
+
throw new Error(`entwurf-bridge: ${identity.backend} resolved as self-fetch without the sender marker its receiver join requires`);
|
|
222
|
+
}
|
|
255
223
|
const receiver = resolveMailboxReceiverFacts(identity, {
|
|
256
224
|
readReceiverMarker: (gardenId) => readMetaReceiverMarker({ gardenId }),
|
|
257
225
|
readSenderMarker: (backend, ownerPid) => readMetaSenderMarker({ backend: requireBackend(backend), ownerPid }),
|
|
@@ -274,7 +242,7 @@ async function buildTrustedMetaSenderEnvelope(cwd = process.cwd()) {
|
|
|
274
242
|
envelope: {
|
|
275
243
|
sessionId: identity.gardenId,
|
|
276
244
|
agentId: `meta-session/${identity.backend}`,
|
|
277
|
-
cwd
|
|
245
|
+
cwd,
|
|
278
246
|
timestamp: new Date().toISOString(),
|
|
279
247
|
origin: "meta-session",
|
|
280
248
|
replyable: self.replyable,
|
|
@@ -282,17 +250,43 @@ async function buildTrustedMetaSenderEnvelope(cwd = process.cwd()) {
|
|
|
282
250
|
metaDeliveryDomain,
|
|
283
251
|
};
|
|
284
252
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
253
|
+
async function resolveAuthoritativeSender(context, cwd = process.cwd()) {
|
|
254
|
+
const sessionId = process.env.PI_SESSION_ID?.trim();
|
|
255
|
+
const agentId = process.env.PI_AGENT_ID?.trim();
|
|
256
|
+
const pi = sessionId && agentId && cwd ? buildStrictPiSenderEnvelope() : null;
|
|
257
|
+
const marker = resolveTrustedMetaSenderIdentity({
|
|
258
|
+
markerPath: process.env.ENTWURF_META_SENDER_MARKER?.trim() || undefined,
|
|
259
|
+
});
|
|
260
|
+
const codex = resolveCodexRequestSenderIdentity({
|
|
261
|
+
provenance: process.env.ENTWURF_BRIDGE_NATIVE_HOST,
|
|
262
|
+
clientInfo: server.server.getClientVersion(),
|
|
263
|
+
requestMeta: context.requestMeta,
|
|
264
|
+
});
|
|
265
|
+
const selected = reconcileSenderIdentityClaims([
|
|
266
|
+
...(pi ? [{ rail: "pi-session", id: pi.sessionId }] : []),
|
|
267
|
+
...(marker ? [{ rail: "meta-sender-marker", id: marker.identity.gardenId }] : []),
|
|
268
|
+
...(codex ? [{ rail: "codex-request", id: codex.identity.gardenId }] : []),
|
|
269
|
+
]);
|
|
270
|
+
if (!selected)
|
|
271
|
+
return null;
|
|
272
|
+
if (codex && codex.identity.gardenId === selected.id) {
|
|
273
|
+
return { ...(await buildMetaSenderEnvelope(codex.identity, codex.identity.cwd)), codexThreadId: codex.threadId };
|
|
274
|
+
}
|
|
275
|
+
if (marker && marker.identity.gardenId === selected.id) {
|
|
276
|
+
return buildMetaSenderEnvelope(marker.identity, marker.marker.cwd || cwd, marker);
|
|
277
|
+
}
|
|
278
|
+
if (pi && pi.sessionId === selected.id)
|
|
279
|
+
return { envelope: pi };
|
|
280
|
+
throw new Error(`entwurf-bridge: reconciled sender ${selected.id} has no matching identity source`);
|
|
281
|
+
}
|
|
282
|
+
// Async only when the selected sender rides native-push: replyability is the adapter's live fact.
|
|
283
|
+
async function buildAuthoritativeSelfEnvelope(context = {}) {
|
|
284
|
+
const resolved = await resolveAuthoritativeSender(context);
|
|
285
|
+
if (resolved)
|
|
286
|
+
return resolved;
|
|
288
287
|
const sessionId = process.env.PI_SESSION_ID?.trim();
|
|
289
288
|
const agentId = process.env.PI_AGENT_ID?.trim();
|
|
290
289
|
const cwd = process.cwd();
|
|
291
|
-
if (sessionId && agentId && cwd)
|
|
292
|
-
return { envelope: buildStrictPiSenderEnvelope() };
|
|
293
|
-
const meta = await buildTrustedMetaSenderEnvelope(cwd);
|
|
294
|
-
if (meta)
|
|
295
|
-
return meta;
|
|
296
290
|
const missing = [];
|
|
297
291
|
if (!sessionId)
|
|
298
292
|
missing.push("PI_SESSION_ID");
|
|
@@ -302,18 +296,11 @@ async function buildAuthoritativeSelfEnvelope() {
|
|
|
302
296
|
missing.push("cwd");
|
|
303
297
|
throw new EntwurfEnvelopeWiringError(missing);
|
|
304
298
|
}
|
|
305
|
-
async function buildSendSenderEnvelope() {
|
|
306
|
-
const
|
|
307
|
-
|
|
299
|
+
async function buildSendSenderEnvelope(context = {}) {
|
|
300
|
+
const resolved = await resolveAuthoritativeSender(context);
|
|
301
|
+
if (resolved)
|
|
302
|
+
return resolved.envelope;
|
|
308
303
|
const cwd = process.cwd();
|
|
309
|
-
if (sessionId && agentId && cwd)
|
|
310
|
-
return buildStrictPiSenderEnvelope();
|
|
311
|
-
const meta = await buildTrustedMetaSenderEnvelope(cwd);
|
|
312
|
-
// Delivery takes the WIRE envelope only — the rail axis is rendering-local.
|
|
313
|
-
if (meta)
|
|
314
|
-
return meta.envelope;
|
|
315
|
-
// No marker. #50 C4: anonymous external is refused UNLESS the operator wired the
|
|
316
|
-
// explicit escape hatch — identity-required is the default, not an install flag.
|
|
317
304
|
if (process.env.ENTWURF_BRIDGE_ALLOW_ANONYMOUS_SENDER !== "1") {
|
|
318
305
|
throw new EntwurfSenderIdentityError();
|
|
319
306
|
}
|
|
@@ -365,12 +352,11 @@ server.tool("entwurf_v2", "CANONICAL DELIVERY SURFACE for garden ids: message, r
|
|
|
365
352
|
"outcome (delivered / rejected / delivered-but-lock-dirty). EXISTING targets only; discover with " +
|
|
366
353
|
"entwurf_peers. A peer entwurf_peers shows as liveness=alive → fire-and-forget. A " +
|
|
367
354
|
"citizen with NO socket liveness (liveness=unsupported) is ALSO fire-and-forget — unsupported means only " +
|
|
368
|
-
'"no control-socket probe"
|
|
369
|
-
"
|
|
370
|
-
"
|
|
371
|
-
"
|
|
372
|
-
"
|
|
373
|
-
"native-push-target-dead; indeterminate → native-push-probe-indeterminate (unestablished ≠ gone). " +
|
|
355
|
+
'"no control-socket probe." The decider resolves the actual rail at dispatch time: a self-fetch ' +
|
|
356
|
+
"mailbox only while deliverable, native-push direct injection only while its adapter probe is alive, " +
|
|
357
|
+
"or a THIRD RESULT: rejection when neither holds. An inactive self-fetch citizen is mailbox-undeliverable; " +
|
|
358
|
+
"native-push has NO mailbox: dead → native-push-target-dead; indeterminate → " +
|
|
359
|
+
"native-push-probe-indeterminate. " +
|
|
374
360
|
"DORMANT IS UNREACHABLE: a socket-domain citizen that is not running gets dormant-fire-forget-unsupported " +
|
|
375
361
|
"— same receiver rule as the mailbox, no active drainer means no delivery. " +
|
|
376
362
|
"The intent that used to answer there, owned-outcome, resumed it by launching a hidden background child " +
|
|
@@ -402,11 +388,11 @@ server.tool("entwurf_v2", "CANONICAL DELIVERY SURFACE for garden ids: message, r
|
|
|
402
388
|
"follow_up (queue after it). The mailbox and native-push plans carry no mode, so it has no " +
|
|
403
389
|
"effect on those rails."),
|
|
404
390
|
wants_reply: z.boolean().optional().describe("Human-conversation reply hint (default false)"),
|
|
405
|
-
}, async ({ target, intent, message, mode, wants_reply }) => {
|
|
391
|
+
}, async ({ target, intent, message, mode, wants_reply }, extra) => {
|
|
406
392
|
try {
|
|
407
393
|
// Resolved ONCE so the dispatch-moment timestamp is fixed and the control RPC sender
|
|
408
394
|
// + the mailbox body sender share one envelope. No replyability gate (see above).
|
|
409
|
-
const sender = await buildSendSenderEnvelope();
|
|
395
|
+
const sender = await buildSendSenderEnvelope({ requestMeta: extra._meta });
|
|
410
396
|
const rendered = await runAndRenderEntwurfV2FromSurface({ target, intent, message, mode, wants_reply },
|
|
411
397
|
// No trust-preflight inputs are passed, and none exist to pass: the preflight on this
|
|
412
398
|
// path guarded the resume verdict and left with `owned-outcome`. `senderProvider` is
|
|
@@ -424,15 +410,14 @@ server.tool("entwurf_self", "Return this caller's authoritative identity envelop
|
|
|
424
410
|
"degrading it to nothing would erase who-sent. Use to confirm WHO you " +
|
|
425
411
|
"are (agentId, sessionId), FROM WHERE (cwd), and WHEN this snapshot was taken. " +
|
|
426
412
|
"Works for pi sessions (PI_SESSION_ID / PI_AGENT_ID) and for garden-native meta-sessions, whose " +
|
|
427
|
-
"garden id comes from
|
|
428
|
-
"
|
|
429
|
-
"
|
|
430
|
-
"
|
|
431
|
-
"
|
|
432
|
-
"
|
|
433
|
-
"authoritative reply address.", {}, async () => {
|
|
413
|
+
"garden id comes from the matching record plus either a trusted native process marker or Codex's " +
|
|
414
|
+
"request-scoped thread selector. For a meta-session it also reports WHICH rail a reply rides, " +
|
|
415
|
+
"because that differs by backend: a self-fetch citizen has a drainable mailbox, while a " +
|
|
416
|
+
"native-push citizen has NO mailbox at all — a reply is injected straight into its live native " +
|
|
417
|
+
"conversation/thread. Do not expect a mailbox just because origin is meta-session. Throws for " +
|
|
418
|
+
"plain anonymous external MCP hosts because they have no authoritative reply address.", {}, async (_args, request) => {
|
|
434
419
|
try {
|
|
435
|
-
const self = await buildAuthoritativeSelfEnvelope();
|
|
420
|
+
const self = await buildAuthoritativeSelfEnvelope({ requestMeta: request._meta });
|
|
436
421
|
const sender = self.envelope;
|
|
437
422
|
const kst = formatKstTimestamp(sender.timestamp);
|
|
438
423
|
const extra = {};
|
|
@@ -522,6 +507,9 @@ server.tool("entwurf_peers", "List the entwurf fact surface: garden citizens (fr
|
|
|
522
507
|
readRecord: makeStoreRecordReader(sessionsDir),
|
|
523
508
|
// Socket axis: the same dir dispatch uses (grammar SSOT), scan-internal only.
|
|
524
509
|
socket: { dir: ENTWURF_DIR },
|
|
510
|
+
// #112: observe only rows this human surface can render. Full store facts,
|
|
511
|
+
// diagnostics, liveness, and machine payload remain intact.
|
|
512
|
+
observationLimit: ENTWURF_PEERS_RENDER_LIMIT,
|
|
525
513
|
});
|
|
526
514
|
const { text } = renderEntwurfPeers(result);
|
|
527
515
|
return textOk(text);
|
|
@@ -610,29 +598,29 @@ server.tool("entwurf_register_native", "Register an ALREADY-RUNNING native conve
|
|
|
610
598
|
// its own garden id, a fresh cell answered with its uuidv7 `PI_SESSION_ID` value read out of the
|
|
611
599
|
// environment by an MCP server it had spawned itself — confidently, and wrong. A sibling launched
|
|
612
600
|
// against that answer would call home to a garden id nobody holds.
|
|
613
|
-
server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operator's tmux and hand it a first task.
|
|
614
|
-
"backends only: pi, claude-code, copilot, omp. The sibling's FIRST action is a callback to you carrying a nonce, and the " +
|
|
601
|
+
server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operator's tmux and hand it a first task. Five fixed " +
|
|
602
|
+
"backends only: pi, claude-code, copilot, omp, codex. The sibling's FIRST action is a callback to you carrying a nonce, and the " +
|
|
615
603
|
"sender envelope of that callback is its garden id — that is how you learn the address of something that " +
|
|
616
604
|
"did not exist a moment ago. This returns a LAUNCH receipt (tmux window/pane plus that nonce) and nothing " +
|
|
617
605
|
"else: it does NOT mean the runtime started, the first turn ran, or the task was delivered. Nothing polls " +
|
|
618
|
-
"for the callback; if it never arrives the window is visible
|
|
606
|
+
"for the callback; if it never arrives the window is visible. For EXISTING " +
|
|
619
607
|
"citizens use entwurf_v2 — this tool only creates, and entwurf_peers only reports. Model is REQUIRED and " +
|
|
620
608
|
"is passed to the chosen runtime CLI (`provider/model` for pi; model id/alias for Claude Code; a model name " +
|
|
621
|
-
"or `auto` for copilot; a fuzzy model pattern for omp).
|
|
622
|
-
"
|
|
623
|
-
"
|
|
624
|
-
"visible-status units, or if omp's tools.xdev is not false (the vendor default hides MCP tool schemas from the " +
|
|
625
|
-
"prompt, so the sibling could not call you back at all). An optional " +
|
|
609
|
+
"or `auto` for copilot; a fuzzy model pattern for omp or codex). Copilot, omp, and codex are refused BEFORE " +
|
|
610
|
+
"any window opens when their required birth, MCP, receive/delivery, or visible-identity units are absent; " +
|
|
611
|
+
"Codex also requires the operator-owned default app-server socket, which entwurf never starts. An optional " +
|
|
626
612
|
"cwd starts the sibling in ONE literal absolute existing directory (cross-repo fresh) — never pick resume " +
|
|
627
|
-
"for a dormant record's cwd
|
|
628
|
-
"An optional placement.tmuxSession
|
|
629
|
-
"
|
|
613
|
+
"for a dormant record's cwd. Omitted/empty cwd means the caller's own directory. " +
|
|
614
|
+
"An optional placement.tmuxSession is an expert override naming ONE EXISTING session on this agent's own " +
|
|
615
|
+
"tmux server, and it ALWAYS wins. Omitted, the seat follows the CALLER: a CODEX CALLER opens beside its own " +
|
|
616
|
+
"TUI pane (matched by thread-id in that pane's title; 0 or 2+ matches REFUSE, never fall back), and every " +
|
|
617
|
+
"other caller opens in its own session. " +
|
|
618
|
+
"A missing named session is tmux-session-missing and NOTHING is created. " +
|
|
630
619
|
"There are no arbitrary command/env knobs. Do not put secrets in the task — model and task argv are visible to " +
|
|
631
|
-
"same-user processes on this host. Requires that this agent itself runs "
|
|
632
|
-
"inside tmux: without a pane anchor there is no session to open a sibling beside.", {
|
|
620
|
+
"same-user processes on this host. Requires that this agent itself runs inside tmux.", {
|
|
633
621
|
backend: z
|
|
634
|
-
.enum(["pi", "claude-code", "copilot", "omp"])
|
|
635
|
-
.describe("Which fixed runtime to open. Only these
|
|
622
|
+
.enum(["pi", "claude-code", "copilot", "omp", "codex"])
|
|
623
|
+
.describe("Which fixed runtime to open. Only these five; there is no arbitrary command."),
|
|
636
624
|
model: z
|
|
637
625
|
.string()
|
|
638
626
|
.min(1)
|
|
@@ -646,7 +634,7 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operato
|
|
|
646
634
|
// as an unused suppression while the escape goes back to being fixable.
|
|
647
635
|
// biome-ignore lint/complexity/noUselessEscapeInRegex: emitted to a Rust regex validator, see above
|
|
648
636
|
.regex(/^[A-Za-z0-9][A-Za-z0-9._/:\[\]-]*$/)
|
|
649
|
-
.describe("Required runtime model: canonical provider/model for pi, a Claude Code model id/alias, or a Copilot model name
|
|
637
|
+
.describe("Required runtime model: canonical provider/model for pi, a Claude Code model id/alias, or a Copilot/OMP/Codex model name."),
|
|
650
638
|
task: z
|
|
651
639
|
.string()
|
|
652
640
|
.min(1)
|
|
@@ -655,7 +643,7 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operato
|
|
|
655
643
|
cwd: z
|
|
656
644
|
.string()
|
|
657
645
|
.optional()
|
|
658
|
-
.describe("Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh).
|
|
646
|
+
.describe("Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh). Taken exactly as given — no trim, no realpath, no project-name resolution; '#' is refused (tmux format expansion). Omit or pass \"\" to start where the CALLER is: its own directory, or for a Codex caller its own record directory, because this bridge is the app-server's child and that process's directory is not the caller's. The receipt echoes the directory that was REQUESTED or the caller record it came from, never an observation of where the pane landed."),
|
|
659
647
|
placement: z
|
|
660
648
|
.object({
|
|
661
649
|
tmuxSession: z
|
|
@@ -663,12 +651,26 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operato
|
|
|
663
651
|
.describe("EXACT name of an EXISTING session on this agent's own tmux server. Nothing is created: an absent session is refused as tmux-session-missing, and a name outside [A-Za-z0-9][A-Za-z0-9_-]* as tmux-session-name-invalid."),
|
|
664
652
|
})
|
|
665
653
|
.optional()
|
|
666
|
-
.describe("Optional
|
|
667
|
-
}, async ({ backend, model, task, cwd, placement }) => {
|
|
654
|
+
.describe("Optional expert seat override: open the sibling in ONE EXISTING tmux session of this agent's own server, and it always wins. When omitted the seat follows the CALLER, never the backend being opened: a Codex CALLER opens beside its own TUI pane, matched by thread-id in that pane's terminal title (0 or 2+ matching panes refuse, never fall back); every other caller opens in its own session. A pane title is a placement input only — never an address, liveness or delivery fact. Nothing is ever created. Independent of cwd; neither is inferred from the other. The receipt reports the selected name (absent for the caller-pane rule, which observed a session rather than requesting a name), its source, and resolved target session id."),
|
|
655
|
+
}, async ({ backend, model, task, cwd, placement }, extra) => {
|
|
668
656
|
let callerGardenId = null;
|
|
657
|
+
// Present exactly when the reconciled caller is a record-backed codex citizen. Its ONLY
|
|
658
|
+
// consumer is the placement below: a Codex caller with no explicit seat opens its sibling
|
|
659
|
+
// beside its own TUI pane, found by that thread's terminal title (#95 lane B).
|
|
660
|
+
let callerNativeSessionId;
|
|
661
|
+
// The SAME citizen's record cwd, set under the SAME condition and carrying no further
|
|
662
|
+
// authority: `envelope.cwd` is only the record's directory when the selected sender is
|
|
663
|
+
// that codex citizen, so the two are derived from one fact rather than read separately.
|
|
664
|
+
// It exists because `process.cwd()` here is the operator-owned APP-SERVER's directory —
|
|
665
|
+
// this bridge is its MCP child — so a codex caller that names no cwd would otherwise open
|
|
666
|
+
// every sibling in the app-server's repo (#95 lane C §1). The composition consults it only
|
|
667
|
+
// when the tool call requested no cwd of its own.
|
|
668
|
+
let callerCwd;
|
|
669
669
|
try {
|
|
670
|
-
const self = await buildAuthoritativeSelfEnvelope();
|
|
670
|
+
const self = await buildAuthoritativeSelfEnvelope({ requestMeta: extra._meta });
|
|
671
671
|
callerGardenId = self.envelope.sessionId;
|
|
672
|
+
callerNativeSessionId = self.codexThreadId;
|
|
673
|
+
callerCwd = self.codexThreadId === undefined ? undefined : self.envelope.cwd;
|
|
672
674
|
}
|
|
673
675
|
catch (err) {
|
|
674
676
|
// ONE error is a legitimate answer here: this host has no authoritative identity at all
|
|
@@ -683,7 +685,21 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operato
|
|
|
683
685
|
callerGardenId = null;
|
|
684
686
|
}
|
|
685
687
|
try {
|
|
686
|
-
|
|
688
|
+
// TWO capability axes, in this order, both pre-mutation and neither standing in for
|
|
689
|
+
// the other. The TARGET axis first — "entwurf cannot open a Codex sibling here at
|
|
690
|
+
// all" is the more fundamental answer than "and it would not know where to put it".
|
|
691
|
+
// The CALLER axis second, and only when the seat anchor will actually be consulted:
|
|
692
|
+
// a codex caller that named an explicit placement never reads a pane title, so
|
|
693
|
+
// refusing it for a missing `thread-id` would refuse an unused capability.
|
|
694
|
+
const targetMissing = backend === "codex" ? await codexFreshPreflight(process.env) : null;
|
|
695
|
+
const callerMissing = targetMissing === null && callerNativeSessionId !== undefined && placement === undefined
|
|
696
|
+
? codexCallerFreshPreflight(process.env)
|
|
697
|
+
: null;
|
|
698
|
+
const missing = targetMissing ?? callerMissing;
|
|
699
|
+
const result = missing
|
|
700
|
+
? { ok: false, reason: missing }
|
|
701
|
+
: freshCall({ backend, model, task, cwd, placement, callerGardenId, callerNativeSessionId, callerCwd });
|
|
702
|
+
const rendered = renderFreshCall(result);
|
|
687
703
|
return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
|
|
688
704
|
}
|
|
689
705
|
catch (err) {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex-caller-seat — the ONE resolution of a Codex caller's `threadId` into the tmux pane its
|
|
3
|
+
* TUI is sitting in. Narrow leaf of the fresh-call composition (#95 lane B); it owns the title
|
|
4
|
+
* anchor and the pane count and NOTHING else — it never runs tmux (the runner is injected),
|
|
5
|
+
* never phrases a placement decision, never reads a record, and has no fallback seat.
|
|
6
|
+
*
|
|
7
|
+
* Same shape and same discipline as `resolve-tmux-session.ts` and `classify-tmux-cwd.ts`: this
|
|
8
|
+
* file imports nothing at all, not even a node builtin, so it stays deletable on its own and
|
|
9
|
+
* cannot acquire an opinion about mux, entwurf, identity or delivery. The injected runner is
|
|
10
|
+
* matched STRUCTURALLY to `mux-placement.TmuxRun` rather than by a type import, for the same
|
|
11
|
+
* reason.
|
|
12
|
+
*
|
|
13
|
+
* ── WHAT THIS IS NOT ──
|
|
14
|
+
*
|
|
15
|
+
* A pane title is OPERATOR-WRITABLE and forgeable: any process in any pane can emit the same
|
|
16
|
+
* OSC 0 string. So this leaf's answer is a PLACEMENT INPUT and nothing else — the `$session`
|
|
17
|
+
* it returns may only reach a `-t` target. Identity, delivery and liveness keep the record +
|
|
18
|
+
* `_meta` join they already have (AGENTS.md Hard Rule 16). Nothing here reads screen text,
|
|
19
|
+
* sends keys, or infers that a citizen is alive.
|
|
20
|
+
*
|
|
21
|
+
* ── THE ANCHOR, AND WHY IT IS NOT THE BARE UUID ──
|
|
22
|
+
*
|
|
23
|
+
* With `thread-id` in `[tui].terminal_title` (the `entwurf install-codex-terminal-title` atom)
|
|
24
|
+
* the vendor renders the thread UUID into the terminal title — but TRUNCATED. `[측정]
|
|
25
|
+
* 2026-09-16, thinkpad, codex-cli 0.153.4: a live TUI's `#{pane_title}` read back
|
|
26
|
+
* `tmp | 01a0a7f9-ed9c-7aa2-a4dd-b1a39...`, not the 36-char id. Source at `rust-v0.153.4`:
|
|
27
|
+
* `codex-rs/tui/src/chatwidget/status_surfaces.rs:892-894` renders `TerminalTitleItem::SessionId`
|
|
28
|
+
* through `truncate_terminal_title_part(value, 32)`, and `:1027-1043` keeps 29 graphemes and
|
|
29
|
+
* appends `...`. So the anchor set is TWO strings: the truncated form the vendor emits today,
|
|
30
|
+
* and the full id — accepted so that a vendor which later stops truncating passes unchanged
|
|
31
|
+
* rather than silently resolving nothing.
|
|
32
|
+
*
|
|
33
|
+
* `[측정]` 29 leading chars of a thread UUID are ASCII (`01a09ec6-e6de-7643-8571-f3261`, 29
|
|
34
|
+
* code points, 29 bytes — terra#1 2026-09-16 item 8), so counting code points here and
|
|
35
|
+
* graphemes there is the same count for every id this rail will ever see.
|
|
36
|
+
*
|
|
37
|
+
* `[측정]` `rust-v0.154.0` is unchanged on all three axes — the `thread-id` item
|
|
38
|
+
* (`title_setup.rs:81-83`), the 32-char truncation (`status_surfaces.rs:930-934`) and the
|
|
39
|
+
* ` | ` separator (`title_setup.rs:187-195`) — so this anchor is not pinned to one release.
|
|
40
|
+
*
|
|
41
|
+
* ── WHY TOKENS AND NOT ` | ` SEGMENTS ──
|
|
42
|
+
*
|
|
43
|
+
* The obvious rule is "one ` | `-separated segment equals the anchor". It has a vendor hole.
|
|
44
|
+
* `title_setup.rs:183-193 separator_from_previous` joins adjacent items with ` | ` EXCEPT when
|
|
45
|
+
* either side is the `activity` item (`Spinner`, `title_setup.rs:47-48`), which gets a plain
|
|
46
|
+
* space — and `status_surfaces.rs:330-343` computes that `previous` from the last RENDERED
|
|
47
|
+
* item, skipping any that resolved to `None`. So on a host whose operator list ends in
|
|
48
|
+
* `activity`, the installed atom appends `thread-id` right after it and a WORKING TUI renders
|
|
49
|
+
* `<spinner text> 01a0a7f9-…`: one segment, two values. Splitting each segment on a space and
|
|
50
|
+
* comparing TOKENS closes that hole and cannot open a new one — a false positive would need
|
|
51
|
+
* some other title item to render a string byte-identical to this thread's own id.
|
|
52
|
+
*
|
|
53
|
+
* ── WHY THE COUNT IS OVER PANES, NOT TOKENS ──
|
|
54
|
+
*
|
|
55
|
+
* One pane can legitimately show the same thread twice: an operator whose `terminal_title` also
|
|
56
|
+
* carries `thread-title` sees the full 36-char id there whenever the thread is unnamed
|
|
57
|
+
* (`status_surfaces.rs:776-786`), beside our truncated one. Two tokens, one pane, one thread —
|
|
58
|
+
* not an ambiguity. Ambiguity is TWO PANES claiming the same thread, and that is refused
|
|
59
|
+
* because picking either would seat a sibling by guess.
|
|
60
|
+
*/
|
|
61
|
+
/** The repair text for each refusal, owned by the leaf that decides it so the sentence an
|
|
62
|
+
* operator reads cannot drift away from the predicate that produced it (the same rule the
|
|
63
|
+
* Copilot/OMP/Codex preflight hints follow). */
|
|
64
|
+
export const CODEX_CALLER_SEAT_HINT = {
|
|
65
|
+
"codex-caller-seat-unresolved": "no pane on this agent's own tmux server shows this Codex thread in its title, so there is no caller seat to open a sibling beside — the TUI may be on another tmux server or outside tmux entirely, its config may not carry `thread-id` in [tui].terminal_title (run `entwurf install-codex-terminal-title`, then `entwurf doctor-codex-terminal-title`), or that server may have `allow-set-title off`, which replaces every pane title with the hostname and hides the id [측정 2026-09-16]",
|
|
66
|
+
"codex-caller-seat-ambiguous": "more than one pane on this agent's own tmux server shows this Codex thread in its title, so which one is the caller cannot be decided — nothing is opened rather than guessing a seat; close the stale duplicate and call again",
|
|
67
|
+
};
|
|
68
|
+
/** The vendor's per-item ceiling for `thread-id` (`status_surfaces.rs:892-894`). */
|
|
69
|
+
export const CODEX_TITLE_ITEM_MAX_CHARS = 32;
|
|
70
|
+
/** The two separators a rendered title can put between items: ` | ` for an ordinary pair, and a
|
|
71
|
+
* bare space when either neighbour is the `activity` indicator (`title_setup.rs:183-193`). */
|
|
72
|
+
const TITLE_SEGMENT_SEPARATOR = " | ";
|
|
73
|
+
const TITLE_TOKEN_SEPARATOR = " ";
|
|
74
|
+
/**
|
|
75
|
+
* The vendor's `truncate_terminal_title_part`, reproduced (`status_surfaces.rs:1027-1043`).
|
|
76
|
+
* Code points stand in for graphemes — measured identical for every thread id (see header).
|
|
77
|
+
*/
|
|
78
|
+
export function truncateTerminalTitlePart(value, maxChars) {
|
|
79
|
+
const chars = [...value];
|
|
80
|
+
if (chars.length <= maxChars || maxChars <= 3)
|
|
81
|
+
return chars.slice(0, maxChars).join("");
|
|
82
|
+
return `${chars.slice(0, maxChars - 3).join("")}...`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Every string a title may legitimately carry for this thread. The truncated form is what the
|
|
86
|
+
* vendor emits today; the full id is accepted so a future vendor that stops truncating keeps
|
|
87
|
+
* working without a code change here.
|
|
88
|
+
*/
|
|
89
|
+
export function codexTitleAnchors(threadId) {
|
|
90
|
+
if (threadId.length === 0) {
|
|
91
|
+
throw new Error("codex-caller-seat: refusing to build an anchor for an empty threadId");
|
|
92
|
+
}
|
|
93
|
+
const truncated = truncateTerminalTitlePart(threadId, CODEX_TITLE_ITEM_MAX_CHARS);
|
|
94
|
+
return truncated === threadId ? [threadId] : [threadId, truncated];
|
|
95
|
+
}
|
|
96
|
+
/** Does this ONE pane title name the thread? Segment first, then token — see the header for the
|
|
97
|
+
* `activity`-adjacency hole a segment-only rule leaves open. */
|
|
98
|
+
export function titleNamesThread(title, anchors) {
|
|
99
|
+
for (const segment of title.split(TITLE_SEGMENT_SEPARATOR)) {
|
|
100
|
+
for (const token of segment.split(TITLE_TOKEN_SEPARATOR)) {
|
|
101
|
+
if (anchors.includes(token))
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The lookup argv. `-a` is the whole server because a Codex TUI is not required to be in the
|
|
109
|
+
* caller's own session — that asymmetry is exactly what this leaf exists to remove. The three
|
|
110
|
+
* fields are tab-separated so a title containing spaces (a project name, the spinner text)
|
|
111
|
+
* cannot be read as a new column.
|
|
112
|
+
*/
|
|
113
|
+
export function buildCodexCallerSeatArgs() {
|
|
114
|
+
return ["list-panes", "-a", "-F", "#{pane_id}\t#{session_id}\t#{pane_title}"];
|
|
115
|
+
}
|
|
116
|
+
/** One `list-panes` line back into its three fields, or `null` when it is not one. The title
|
|
117
|
+
* takes EVERYTHING after the second tab: splitting on every tab would truncate a title that
|
|
118
|
+
* ever carried one. */
|
|
119
|
+
function parsePaneLine(line) {
|
|
120
|
+
const firstTab = line.indexOf("\t");
|
|
121
|
+
if (firstTab < 0)
|
|
122
|
+
return null;
|
|
123
|
+
const secondTab = line.indexOf("\t", firstTab + 1);
|
|
124
|
+
if (secondTab < 0)
|
|
125
|
+
return null;
|
|
126
|
+
const paneId = line.slice(0, firstTab);
|
|
127
|
+
const sessionId = line.slice(firstTab + 1, secondTab);
|
|
128
|
+
if (paneId.length === 0 || sessionId.length === 0)
|
|
129
|
+
return null;
|
|
130
|
+
return { paneId, sessionId, title: line.slice(secondTab + 1) };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the pane a Codex caller's thread is displayed in, on whatever server the runner's
|
|
134
|
+
* environment names.
|
|
135
|
+
*
|
|
136
|
+
* ONE BOUNDED IMPRECISION, STATED RATHER THAN LAUNDERED (the same one
|
|
137
|
+
* `resolve-tmux-session.ts` carries): rc≠0 also covers "no server running on this socket". This
|
|
138
|
+
* leaf reads every rc≠0 as `codex-caller-seat-unresolved`, so a server that died between the
|
|
139
|
+
* caller's context proof and this lookup is reported under the narrower word. That is safe —
|
|
140
|
+
* both readings are refusals that mutate nothing, and the hint above names both — and it is
|
|
141
|
+
* preferred over matching tmux's own stderr text, which would pin this leaf to one vendor
|
|
142
|
+
* version's wording.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveCodexCallerSeat(threadId, run) {
|
|
145
|
+
const anchors = codexTitleAnchors(threadId);
|
|
146
|
+
const result = run(buildCodexCallerSeatArgs());
|
|
147
|
+
// A signalled call is not tmux answering — it carries no information about any pane at all,
|
|
148
|
+
// so it must never be read as "the caller's TUI is not here".
|
|
149
|
+
if (result.status === null) {
|
|
150
|
+
throw new Error(`codex-caller-seat: the pane listing was killed by a signal: ${result.stderr.trim()}`);
|
|
151
|
+
}
|
|
152
|
+
if (result.status !== 0)
|
|
153
|
+
return { ok: false, reason: "codex-caller-seat-unresolved" };
|
|
154
|
+
const matches = [];
|
|
155
|
+
for (const line of result.stdout.split("\n")) {
|
|
156
|
+
if (line.length === 0)
|
|
157
|
+
continue;
|
|
158
|
+
const pane = parsePaneLine(line);
|
|
159
|
+
// A line this leaf cannot read is NOT a pane it may skip quietly: the listing is the
|
|
160
|
+
// whole evidence base for "exactly one", and a dropped line could be the second match
|
|
161
|
+
// that should have refused.
|
|
162
|
+
if (pane === null) {
|
|
163
|
+
throw new Error(`codex-caller-seat: tmux printed a pane line this leaf cannot read: ${JSON.stringify(line)}`);
|
|
164
|
+
}
|
|
165
|
+
if (titleNamesThread(pane.title, anchors)) {
|
|
166
|
+
matches.push({ paneId: pane.paneId, sessionId: pane.sessionId, source: "codex-title-anchor" });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (matches.length === 0)
|
|
170
|
+
return { ok: false, reason: "codex-caller-seat-unresolved" };
|
|
171
|
+
if (matches.length > 1)
|
|
172
|
+
return { ok: false, reason: "codex-caller-seat-ambiguous" };
|
|
173
|
+
return { ok: true, seat: matches[0] };
|
|
174
|
+
}
|