@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
|
@@ -81,6 +81,13 @@ import type {
|
|
|
81
81
|
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
82
82
|
import { Box, type Component, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
83
83
|
import { ENTWURF_SENT_MESSAGE_TYPE } from "../protocol.js";
|
|
84
|
+
import {
|
|
85
|
+
type CompactionGuard,
|
|
86
|
+
compactionSendReject,
|
|
87
|
+
createCompactionGuard,
|
|
88
|
+
noteCompactionBefore,
|
|
89
|
+
noteCompactionTerminal,
|
|
90
|
+
} from "./lib/compaction-send-guard.js";
|
|
84
91
|
import { CONTROL_SOCKET_SUFFIX, controlSocketPathIn, defaultControlSocketDir } from "./lib/control-socket-path.js";
|
|
85
92
|
import {
|
|
86
93
|
formatSenderInfoBlock,
|
|
@@ -126,6 +133,7 @@ interface SocketState {
|
|
|
126
133
|
server: net.Server | null;
|
|
127
134
|
socketPath: string | null;
|
|
128
135
|
context: ExtensionContext | null;
|
|
136
|
+
compaction: CompactionGuard;
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
// The resident's GARDEN ADDRESS (#50 C2) — minted by this session's meta-record at
|
|
@@ -754,6 +762,15 @@ async function handleCommand(
|
|
|
754
762
|
}
|
|
755
763
|
}
|
|
756
764
|
|
|
765
|
+
const compactionReject = compactionSendReject(state.compaction, {
|
|
766
|
+
idle: ctx.isIdle(),
|
|
767
|
+
hasAgentSignal: ctx.signal !== undefined,
|
|
768
|
+
});
|
|
769
|
+
if (compactionReject) {
|
|
770
|
+
respond(false, "send", undefined, compactionReject);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
|
|
757
774
|
// wants_reply defaults to false (etiquette marker, not transport contract).
|
|
758
775
|
// It surfaces a "(wants reply)" badge on the receiver render so the
|
|
759
776
|
// human/agent at either end sees that the sender wants a conversational
|
|
@@ -1036,6 +1053,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1036
1053
|
server: null,
|
|
1037
1054
|
socketPath: null,
|
|
1038
1055
|
context: null,
|
|
1056
|
+
compaction: createCompactionGuard(),
|
|
1039
1057
|
};
|
|
1040
1058
|
|
|
1041
1059
|
pi.registerMessageRenderer(SESSION_MESSAGE_TYPE, renderSessionMessage);
|
|
@@ -1152,15 +1170,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
1152
1170
|
// were dead. The typecheck-exclude on this file kept that decay invisible.
|
|
1153
1171
|
// Don't reintroduce them without first confirming the events exist.
|
|
1154
1172
|
pi.on("session_start", async (_event, ctx) => {
|
|
1173
|
+
noteCompactionTerminal(state.compaction);
|
|
1155
1174
|
await refreshServer(ctx);
|
|
1156
1175
|
});
|
|
1157
1176
|
|
|
1177
|
+
pi.on("session_before_compact", () => {
|
|
1178
|
+
noteCompactionBefore(state.compaction);
|
|
1179
|
+
});
|
|
1180
|
+
pi.on("session_compact", () => {
|
|
1181
|
+
noteCompactionTerminal(state.compaction);
|
|
1182
|
+
});
|
|
1183
|
+
pi.on("session_compact_failed", () => {
|
|
1184
|
+
noteCompactionTerminal(state.compaction);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1158
1187
|
// No session_before_switch / session_before_fork guards: `/new`, `/fork`, `/clone`
|
|
1159
1188
|
// and RPC session replacement are pi's own again (#50 C2). Each replacement fires
|
|
1160
1189
|
// session_start, which attaches the new pi session to its own record — the socket
|
|
1161
1190
|
// simply rebinds to the new address. There is no id to police at the pre-event.
|
|
1162
1191
|
|
|
1163
1192
|
pi.on("session_shutdown", async () => {
|
|
1193
|
+
noteCompactionTerminal(state.compaction);
|
|
1164
1194
|
updateStatus(state.context, false, null);
|
|
1165
1195
|
updateSessionEnv(state.context, false, null);
|
|
1166
1196
|
residentGardenId = null;
|
|
@@ -1423,10 +1453,12 @@ interface EntwurfFactProviderModule {
|
|
|
1423
1453
|
metaEntries: readonly { filename: string; regularFile: boolean }[];
|
|
1424
1454
|
readRecord: (filename: string) => string;
|
|
1425
1455
|
socket: { dir: string };
|
|
1456
|
+
observationLimit: number;
|
|
1426
1457
|
}): Promise<unknown>;
|
|
1427
1458
|
}
|
|
1428
1459
|
|
|
1429
1460
|
interface EntwurfPeersRenderModule {
|
|
1461
|
+
ENTWURF_PEERS_RENDER_LIMIT: number;
|
|
1430
1462
|
renderEntwurfPeers(result: unknown): { text: string; payload: unknown };
|
|
1431
1463
|
}
|
|
1432
1464
|
|
|
@@ -1445,14 +1477,17 @@ async function renderEntwurfPeersForSurface(): Promise<{ text: string; payload:
|
|
|
1445
1477
|
// surfaced by the #52 duplicate pass, which would let such a symlink quarantine the
|
|
1446
1478
|
// healthy record it shadowed).
|
|
1447
1479
|
const provider = (await import(ENTWURF_FACT_PROVIDER_MODULE)) as unknown as EntwurfFactProviderModule;
|
|
1480
|
+
const render = (await import(ENTWURF_PEERS_RENDER_MODULE)) as unknown as EntwurfPeersRenderModule;
|
|
1448
1481
|
const result = await provider.listEntwurfFacts({
|
|
1449
1482
|
metaEntries: meta.readActiveStoreEntries(sessionsDir),
|
|
1450
1483
|
readRecord: meta.makeStoreRecordReader(sessionsDir),
|
|
1451
1484
|
// Same socket axis as the legacy live-session scan, but merged with the
|
|
1452
1485
|
// meta-record rail by listEntwurfFacts so meta-mailbox citizens are discoverable too.
|
|
1453
1486
|
socket: { dir: ENTWURF_DIR },
|
|
1487
|
+
// #112: observation follows the human render budget. The full machine payload
|
|
1488
|
+
// and every authority/diagnostic pass above remain complete.
|
|
1489
|
+
observationLimit: render.ENTWURF_PEERS_RENDER_LIMIT,
|
|
1454
1490
|
});
|
|
1455
|
-
const render = (await import(ENTWURF_PEERS_RENDER_MODULE)) as unknown as EntwurfPeersRenderModule;
|
|
1456
1491
|
return render.renderEntwurfPeers(result);
|
|
1457
1492
|
}
|
|
1458
1493
|
|
|
@@ -1496,11 +1531,16 @@ function registerListSessionsTool(pi: ExtensionAPI): void {
|
|
|
1496
1531
|
// ============================================================================
|
|
1497
1532
|
|
|
1498
1533
|
const MUX_FRESH_CALL_MODULE = "./lib/mux-fresh-call.ts";
|
|
1534
|
+
const CODEX_FRESH_PREFLIGHT_MODULE = "./lib/codex-fresh-preflight.ts";
|
|
1535
|
+
|
|
1536
|
+
interface CodexFreshPreflightModule {
|
|
1537
|
+
codexFreshPreflight(env: NodeJS.ProcessEnv): Promise<string | null>;
|
|
1538
|
+
}
|
|
1499
1539
|
|
|
1500
1540
|
interface MuxFreshCallModule {
|
|
1501
1541
|
freshCall(
|
|
1502
1542
|
params: {
|
|
1503
|
-
backend: "pi" | "claude-code" | "copilot" | "omp";
|
|
1543
|
+
backend: "pi" | "claude-code" | "copilot" | "omp" | "codex";
|
|
1504
1544
|
model: string;
|
|
1505
1545
|
task: string;
|
|
1506
1546
|
cwd?: string;
|
|
@@ -1530,35 +1570,34 @@ function registerFreshCallTool(pi: ExtensionAPI): void {
|
|
|
1530
1570
|
registerTool({
|
|
1531
1571
|
name: "entwurf_fresh_call",
|
|
1532
1572
|
label: "Open Fresh Sibling",
|
|
1533
|
-
description: `Open ONE fresh visible sibling in the operator's tmux and hand it a first task.
|
|
1534
|
-
backends only: pi, claude-code, copilot, omp. The sibling's FIRST action is a callback to you carrying a nonce, and the
|
|
1573
|
+
description: `Open ONE fresh visible sibling in the operator's tmux and hand it a first task. Five fixed
|
|
1574
|
+
backends only: pi, claude-code, copilot, omp, codex. The sibling's FIRST action is a callback to you carrying a nonce, and the
|
|
1535
1575
|
sender envelope of that callback is its garden id — that is how you learn the address of something that did
|
|
1536
1576
|
not exist a moment ago. This returns a LAUNCH receipt (tmux window/pane plus that nonce) and nothing else:
|
|
1537
1577
|
it does NOT mean the runtime started, the first turn ran, or the task was delivered. Nothing polls for the
|
|
1538
1578
|
callback; if it never arrives the window is visible and can be read directly. For EXISTING citizens use
|
|
1539
1579
|
entwurf_v2 — this tool only creates, and entwurf_peers only reports. Model is REQUIRED and passed to the
|
|
1540
|
-
chosen runtime CLI (provider/model for pi; model id/alias for Claude Code; a Copilot
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
omp's tools.xdev is not false (the vendor default hides MCP tool schemas from the prompt, so the sibling
|
|
1545
|
-
could not call you back at all). An optional cwd starts the
|
|
1580
|
+
chosen runtime CLI (provider/model for pi; model id/alias for Claude Code; a Copilot, OMP, or Codex model
|
|
1581
|
+
name). Copilot, omp, and codex are refused BEFORE any window opens when their required birth, MCP,
|
|
1582
|
+
receive/delivery, or visible-identity units are absent. Codex additionally requires the operator-owned
|
|
1583
|
+
default app-server socket; entwurf never starts or supervises it. An optional cwd starts the
|
|
1546
1584
|
sibling in ONE literal absolute existing directory (cross-repo fresh) — never pick resume for a dormant
|
|
1547
1585
|
record's cwd; resume is continuity-only. Omitted/empty cwd means the caller's own directory. An optional
|
|
1548
|
-
placement.tmuxSession
|
|
1549
|
-
|
|
1586
|
+
placement.tmuxSession is an expert override naming ONE EXISTING session on this agent's own tmux server and
|
|
1587
|
+
ALWAYS wins. Omitted, the seat follows the CALLER, never the backend being opened: every caller on this
|
|
1588
|
+
surface opens in its own session. A missing named session is tmux-session-missing and NOTHING is created. There are no
|
|
1550
1589
|
arbitrary command/env knobs. Do not put secrets in the task — model and task argv are visible to same-user
|
|
1551
1590
|
processes on this host.`,
|
|
1552
1591
|
parameters: Type.Object({
|
|
1553
|
-
backend: StringEnum(["pi", "claude-code", "copilot", "omp"], {
|
|
1554
|
-
description: "Which fixed runtime to open. Only these
|
|
1592
|
+
backend: StringEnum(["pi", "claude-code", "copilot", "omp", "codex"], {
|
|
1593
|
+
description: "Which fixed runtime to open. Only these five; there is no arbitrary command.",
|
|
1555
1594
|
}),
|
|
1556
1595
|
model: Type.String({
|
|
1557
1596
|
minLength: 1,
|
|
1558
1597
|
maxLength: 200,
|
|
1559
1598
|
pattern: "^[A-Za-z0-9][A-Za-z0-9._/:\\[\\]-]*$",
|
|
1560
1599
|
description:
|
|
1561
|
-
"Required runtime model: canonical provider/model for pi, a Claude Code model id/alias, or a Copilot model name
|
|
1600
|
+
"Required runtime model: canonical provider/model for pi, a Claude Code model id/alias, or a Copilot/OMP/Codex model name.",
|
|
1562
1601
|
}),
|
|
1563
1602
|
task: Type.String({
|
|
1564
1603
|
minLength: 1,
|
|
@@ -1582,7 +1621,7 @@ processes on this host.`,
|
|
|
1582
1621
|
},
|
|
1583
1622
|
{
|
|
1584
1623
|
description:
|
|
1585
|
-
"Optional
|
|
1624
|
+
"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: every caller on THIS surface opens in its own session (a pi session is never a Codex citizen, so the Codex caller-pane rule the MCP bridge carries cannot arise here). Nothing is ever created. Independent of cwd; neither is inferred from the other. The receipt reports the selected name, its source, and resolved target session id.",
|
|
1586
1625
|
},
|
|
1587
1626
|
),
|
|
1588
1627
|
),
|
|
@@ -1590,7 +1629,7 @@ processes on this host.`,
|
|
|
1590
1629
|
async execute(
|
|
1591
1630
|
_toolCallId: string,
|
|
1592
1631
|
params: {
|
|
1593
|
-
backend: "pi" | "claude-code" | "copilot" | "omp";
|
|
1632
|
+
backend: "pi" | "claude-code" | "copilot" | "omp" | "codex";
|
|
1594
1633
|
model: string;
|
|
1595
1634
|
task: string;
|
|
1596
1635
|
cwd?: string;
|
|
@@ -1602,14 +1641,27 @@ processes on this host.`,
|
|
|
1602
1641
|
) {
|
|
1603
1642
|
try {
|
|
1604
1643
|
const mux = (await import(MUX_FRESH_CALL_MODULE)) as unknown as MuxFreshCallModule;
|
|
1605
|
-
|
|
1644
|
+
// ONE input object for ONE composition call. The codex branch differs only by the
|
|
1645
|
+
// capability preflight that must answer BEFORE any mutation; duplicating the call
|
|
1646
|
+
// would put the caller-identity contract in two places, which is how a mutant that
|
|
1647
|
+
// plants a defect in one of them survives on the other.
|
|
1648
|
+
const call = {
|
|
1606
1649
|
backend: params.backend,
|
|
1607
1650
|
model: params.model,
|
|
1608
1651
|
task: params.task,
|
|
1609
1652
|
cwd: params.cwd,
|
|
1610
1653
|
placement: params.placement,
|
|
1611
1654
|
callerGardenId: residentGardenId,
|
|
1612
|
-
}
|
|
1655
|
+
};
|
|
1656
|
+
const result =
|
|
1657
|
+
params.backend === "codex"
|
|
1658
|
+
? await (async () => {
|
|
1659
|
+
const preflight = (await import(CODEX_FRESH_PREFLIGHT_MODULE)) as unknown as CodexFreshPreflightModule;
|
|
1660
|
+
const missing = await preflight.codexFreshPreflight(process.env);
|
|
1661
|
+
if (missing) return { ok: false as const, reason: missing };
|
|
1662
|
+
return mux.freshCall(call);
|
|
1663
|
+
})()
|
|
1664
|
+
: mux.freshCall(call);
|
|
1613
1665
|
const rendered = mux.renderFreshCall(result);
|
|
1614
1666
|
return {
|
|
1615
1667
|
content: [{ type: "text", text: rendered.text }],
|
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
|
|
62
|
+
/** Why a Codex caller's seat could not be resolved. Two stable literals — the consuming
|
|
63
|
+
* composition widens its own reject union with this type, so the strings are contract. */
|
|
64
|
+
export type CodexCallerSeatRejectReason = "codex-caller-seat-unresolved" | "codex-caller-seat-ambiguous";
|
|
65
|
+
|
|
66
|
+
/** The repair text for each refusal, owned by the leaf that decides it so the sentence an
|
|
67
|
+
* operator reads cannot drift away from the predicate that produced it (the same rule the
|
|
68
|
+
* Copilot/OMP/Codex preflight hints follow). */
|
|
69
|
+
export const CODEX_CALLER_SEAT_HINT: Record<CodexCallerSeatRejectReason, string> = {
|
|
70
|
+
"codex-caller-seat-unresolved":
|
|
71
|
+
"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]",
|
|
72
|
+
"codex-caller-seat-ambiguous":
|
|
73
|
+
"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",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** What the injected runner returns. Structurally identical to `mux-placement.TmuxRun`; kept as
|
|
77
|
+
* its own declaration so this leaf imports nothing. */
|
|
78
|
+
export interface CodexCallerSeatRun {
|
|
79
|
+
status: number | null;
|
|
80
|
+
stdout: string;
|
|
81
|
+
stderr: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The pane a Codex caller is sitting in. `sessionId` is the only member a placement may use;
|
|
85
|
+
* `paneId` travels for receipts and diagnosis, never as an address. */
|
|
86
|
+
export interface CodexCallerSeat {
|
|
87
|
+
paneId: string;
|
|
88
|
+
sessionId: string;
|
|
89
|
+
source: "codex-title-anchor";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type CodexCallerSeatResult =
|
|
93
|
+
| { ok: true; seat: CodexCallerSeat }
|
|
94
|
+
| { ok: false; reason: CodexCallerSeatRejectReason };
|
|
95
|
+
|
|
96
|
+
/** The vendor's per-item ceiling for `thread-id` (`status_surfaces.rs:892-894`). */
|
|
97
|
+
export const CODEX_TITLE_ITEM_MAX_CHARS = 32;
|
|
98
|
+
|
|
99
|
+
/** The two separators a rendered title can put between items: ` | ` for an ordinary pair, and a
|
|
100
|
+
* bare space when either neighbour is the `activity` indicator (`title_setup.rs:183-193`). */
|
|
101
|
+
const TITLE_SEGMENT_SEPARATOR = " | ";
|
|
102
|
+
const TITLE_TOKEN_SEPARATOR = " ";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The vendor's `truncate_terminal_title_part`, reproduced (`status_surfaces.rs:1027-1043`).
|
|
106
|
+
* Code points stand in for graphemes — measured identical for every thread id (see header).
|
|
107
|
+
*/
|
|
108
|
+
export function truncateTerminalTitlePart(value: string, maxChars: number): string {
|
|
109
|
+
const chars = [...value];
|
|
110
|
+
if (chars.length <= maxChars || maxChars <= 3) return chars.slice(0, maxChars).join("");
|
|
111
|
+
return `${chars.slice(0, maxChars - 3).join("")}...`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Every string a title may legitimately carry for this thread. The truncated form is what the
|
|
116
|
+
* vendor emits today; the full id is accepted so a future vendor that stops truncating keeps
|
|
117
|
+
* working without a code change here.
|
|
118
|
+
*/
|
|
119
|
+
export function codexTitleAnchors(threadId: string): string[] {
|
|
120
|
+
if (threadId.length === 0) {
|
|
121
|
+
throw new Error("codex-caller-seat: refusing to build an anchor for an empty threadId");
|
|
122
|
+
}
|
|
123
|
+
const truncated = truncateTerminalTitlePart(threadId, CODEX_TITLE_ITEM_MAX_CHARS);
|
|
124
|
+
return truncated === threadId ? [threadId] : [threadId, truncated];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Does this ONE pane title name the thread? Segment first, then token — see the header for the
|
|
128
|
+
* `activity`-adjacency hole a segment-only rule leaves open. */
|
|
129
|
+
export function titleNamesThread(title: string, anchors: readonly string[]): boolean {
|
|
130
|
+
for (const segment of title.split(TITLE_SEGMENT_SEPARATOR)) {
|
|
131
|
+
for (const token of segment.split(TITLE_TOKEN_SEPARATOR)) {
|
|
132
|
+
if (anchors.includes(token)) return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The lookup argv. `-a` is the whole server because a Codex TUI is not required to be in the
|
|
140
|
+
* caller's own session — that asymmetry is exactly what this leaf exists to remove. The three
|
|
141
|
+
* fields are tab-separated so a title containing spaces (a project name, the spinner text)
|
|
142
|
+
* cannot be read as a new column.
|
|
143
|
+
*/
|
|
144
|
+
export function buildCodexCallerSeatArgs(): string[] {
|
|
145
|
+
return ["list-panes", "-a", "-F", "#{pane_id}\t#{session_id}\t#{pane_title}"];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** One `list-panes` line back into its three fields, or `null` when it is not one. The title
|
|
149
|
+
* takes EVERYTHING after the second tab: splitting on every tab would truncate a title that
|
|
150
|
+
* ever carried one. */
|
|
151
|
+
function parsePaneLine(line: string): { paneId: string; sessionId: string; title: string } | null {
|
|
152
|
+
const firstTab = line.indexOf("\t");
|
|
153
|
+
if (firstTab < 0) return null;
|
|
154
|
+
const secondTab = line.indexOf("\t", firstTab + 1);
|
|
155
|
+
if (secondTab < 0) return null;
|
|
156
|
+
const paneId = line.slice(0, firstTab);
|
|
157
|
+
const sessionId = line.slice(firstTab + 1, secondTab);
|
|
158
|
+
if (paneId.length === 0 || sessionId.length === 0) return null;
|
|
159
|
+
return { paneId, sessionId, title: line.slice(secondTab + 1) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the pane a Codex caller's thread is displayed in, on whatever server the runner's
|
|
164
|
+
* environment names.
|
|
165
|
+
*
|
|
166
|
+
* ONE BOUNDED IMPRECISION, STATED RATHER THAN LAUNDERED (the same one
|
|
167
|
+
* `resolve-tmux-session.ts` carries): rc≠0 also covers "no server running on this socket". This
|
|
168
|
+
* leaf reads every rc≠0 as `codex-caller-seat-unresolved`, so a server that died between the
|
|
169
|
+
* caller's context proof and this lookup is reported under the narrower word. That is safe —
|
|
170
|
+
* both readings are refusals that mutate nothing, and the hint above names both — and it is
|
|
171
|
+
* preferred over matching tmux's own stderr text, which would pin this leaf to one vendor
|
|
172
|
+
* version's wording.
|
|
173
|
+
*/
|
|
174
|
+
export function resolveCodexCallerSeat(
|
|
175
|
+
threadId: string,
|
|
176
|
+
run: (args: string[]) => CodexCallerSeatRun,
|
|
177
|
+
): CodexCallerSeatResult {
|
|
178
|
+
const anchors = codexTitleAnchors(threadId);
|
|
179
|
+
const result = run(buildCodexCallerSeatArgs());
|
|
180
|
+
// A signalled call is not tmux answering — it carries no information about any pane at all,
|
|
181
|
+
// so it must never be read as "the caller's TUI is not here".
|
|
182
|
+
if (result.status === null) {
|
|
183
|
+
throw new Error(`codex-caller-seat: the pane listing was killed by a signal: ${result.stderr.trim()}`);
|
|
184
|
+
}
|
|
185
|
+
if (result.status !== 0) return { ok: false, reason: "codex-caller-seat-unresolved" };
|
|
186
|
+
|
|
187
|
+
const matches: CodexCallerSeat[] = [];
|
|
188
|
+
for (const line of result.stdout.split("\n")) {
|
|
189
|
+
if (line.length === 0) continue;
|
|
190
|
+
const pane = parsePaneLine(line);
|
|
191
|
+
// A line this leaf cannot read is NOT a pane it may skip quietly: the listing is the
|
|
192
|
+
// whole evidence base for "exactly one", and a dropped line could be the second match
|
|
193
|
+
// that should have refused.
|
|
194
|
+
if (pane === null) {
|
|
195
|
+
throw new Error(`codex-caller-seat: tmux printed a pane line this leaf cannot read: ${JSON.stringify(line)}`);
|
|
196
|
+
}
|
|
197
|
+
if (titleNamesThread(pane.title, anchors)) {
|
|
198
|
+
matches.push({ paneId: pane.paneId, sessionId: pane.sessionId, source: "codex-title-anchor" });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (matches.length === 0) return { ok: false, reason: "codex-caller-seat-unresolved" };
|
|
202
|
+
if (matches.length > 1) return { ok: false, reason: "codex-caller-seat-ambiguous" };
|
|
203
|
+
return { ok: true, seat: matches[0] };
|
|
204
|
+
}
|