@junghanacs/entwurf 0.20.1 → 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 +4 -3
- package/CHANGELOG.md +195 -14
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +202 -60
- package/README.md +49 -22
- package/VERIFY.md +37 -7
- package/docs/acp-backend-rail.md +0 -1
- package/docs/external-mcp-host.md +54 -33
- 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/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 +13 -4
- package/pi-extensions/entwurf-control.ts +71 -19
- 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 +218 -28
- 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 +6 -3
- package/scripts/check-harness-admission-parity.ts +0 -1
- 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/release-gate.json +13 -0
- package/scripts/mutants/v2-surface.json +53 -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 +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/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.
|
|
1587
|
+
When placement is omitted, Codex targets the exact existing \`codex\` home session; other backends target the
|
|
1588
|
+
caller's session. A missing named/home 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. When omitted, Codex selects the exact existing `codex` home session; other backends use the caller's session. 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,363 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { parse } from "smol-toml";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type CodexProtocolOpener,
|
|
8
|
+
type CodexRpcProtocol,
|
|
9
|
+
type CodexSocketFileCheck,
|
|
10
|
+
checkCodexSocketFile,
|
|
11
|
+
realCodexProtocolOpener,
|
|
12
|
+
resolveCodexDefaultSocketPath,
|
|
13
|
+
} from "./native-push/codex-ws-client.ts";
|
|
14
|
+
|
|
15
|
+
export type CodexPreflightRejectReason =
|
|
16
|
+
| "codex-birth-unit-missing"
|
|
17
|
+
| "codex-mcp-hand-missing"
|
|
18
|
+
| "codex-visible-identity-missing"
|
|
19
|
+
| "codex-birth-trust-missing"
|
|
20
|
+
| "codex-app-server-unavailable";
|
|
21
|
+
|
|
22
|
+
export const CODEX_PREFLIGHT_HINT: Record<CodexPreflightRejectReason, string> = {
|
|
23
|
+
"codex-birth-unit-missing":
|
|
24
|
+
"the Codex SessionStart birth unit is absent, drifted from its recorded digests, or unsafely owned; run `entwurf install-codex-birth`, then `entwurf doctor-codex-birth`",
|
|
25
|
+
"codex-birth-trust-missing":
|
|
26
|
+
"the vendor has recorded no trust receipt for this birth declaration, so the hook is declared and never runs; open a visible plain Codex, answer its prompt with `Trust all and continue`, send one first turn, then `entwurf doctor-codex-birth`",
|
|
27
|
+
"codex-mcp-hand-missing":
|
|
28
|
+
"the Codex user config does not expose the managed entwurf bridge; run `entwurf install-codex-mcp`, then `entwurf doctor-codex-mcp`",
|
|
29
|
+
"codex-visible-identity-missing":
|
|
30
|
+
"Codex tui.status_line does not include thread-title; run `entwurf install-codex-statusline`, then `entwurf doctor-codex-statusline`",
|
|
31
|
+
"codex-app-server-unavailable":
|
|
32
|
+
"the operator-owned Codex app-server default socket is absent or unsafe; start `codex app-server --listen unix://$CODEX_HOME/app-server-control/app-server-control.sock` and retry",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface CodexUnitPaths {
|
|
36
|
+
hooksFile: string;
|
|
37
|
+
helperDir: string;
|
|
38
|
+
stateFile: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CodexPreflightDeps {
|
|
42
|
+
unitPathsOverride?: CodexUnitPaths;
|
|
43
|
+
operatorUid?: number;
|
|
44
|
+
checkSocket?: (socketPath: string) => CodexSocketFileCheck;
|
|
45
|
+
openProtocol?: CodexProtocolOpener;
|
|
46
|
+
appServerTimeoutMs?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The unit's paths are the operator's own, derived from the SAME environment the installer
|
|
51
|
+
* reads — never a system location and never a flag. A preflight that resolved them any other
|
|
52
|
+
* way could pass while the installed unit sat somewhere else.
|
|
53
|
+
*/
|
|
54
|
+
function unitPaths(env: NodeJS.ProcessEnv): CodexUnitPaths {
|
|
55
|
+
const home = env.HOME?.trim() ?? "";
|
|
56
|
+
const codexHome = env.CODEX_HOME?.trim() || path.join(home, ".codex");
|
|
57
|
+
const dataHome = env.XDG_DATA_HOME?.trim() || path.join(home, ".local", "share");
|
|
58
|
+
const unitRoot = path.join(dataHome, "entwurf", "codex-birth");
|
|
59
|
+
return {
|
|
60
|
+
hooksFile: path.join(codexHome, "hooks.json"),
|
|
61
|
+
helperDir: path.join(unitRoot, "helper"),
|
|
62
|
+
stateFile: path.join(unitRoot, "install-state.json"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const CODEX_MCP_ENV_VARS = [
|
|
67
|
+
"CODEX_HOME",
|
|
68
|
+
"ENTWURF_DIR",
|
|
69
|
+
"PI_CODING_AGENT_DIR",
|
|
70
|
+
"ENTWURF_META_SESSIONS_DIR",
|
|
71
|
+
"ENTWURF_META_MAILBOX_DIR",
|
|
72
|
+
"ENTWURF_META_SENDERS_DIR",
|
|
73
|
+
"ENTWURF_META_RECEIVERS_DIR",
|
|
74
|
+
"TMUX",
|
|
75
|
+
"TMUX_PANE",
|
|
76
|
+
] as const;
|
|
77
|
+
|
|
78
|
+
function isSafeOwnedFile(file: string, expectedUid: number, executable = false): boolean {
|
|
79
|
+
try {
|
|
80
|
+
const stat = fs.lstatSync(file);
|
|
81
|
+
return (
|
|
82
|
+
stat.isFile() &&
|
|
83
|
+
!stat.isSymbolicLink() &&
|
|
84
|
+
stat.uid === expectedUid &&
|
|
85
|
+
(stat.mode & 0o022) === 0 &&
|
|
86
|
+
(!executable || (stat.mode & 0o111) !== 0)
|
|
87
|
+
);
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isSafeOwnedDir(dir: string, expectedUid: number): boolean {
|
|
94
|
+
try {
|
|
95
|
+
const stat = fs.lstatSync(dir);
|
|
96
|
+
return stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === expectedUid && (stat.mode & 0o022) === 0;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The unit's own ownership state, read as the digest inventory it is. Bytes that merely
|
|
104
|
+
* EXIST prove nothing: the launcher codex is about to exec must be the one this unit
|
|
105
|
+
* published, so every recorded member is compared to its recorded digest before a sibling
|
|
106
|
+
* is opened. No digest is computed for the VENDOR here — that is a different axis below.
|
|
107
|
+
*/
|
|
108
|
+
function closureDriftedFromState(paths: CodexUnitPaths, expectedUid: number): boolean {
|
|
109
|
+
// The directory holding the state carries the state's authority: anyone who can write it
|
|
110
|
+
// can replace the inventory every digest below is compared against.
|
|
111
|
+
if (!isSafeOwnedDir(path.dirname(paths.stateFile), expectedUid)) return true;
|
|
112
|
+
if (!isSafeOwnedDir(path.dirname(path.dirname(paths.stateFile)), expectedUid)) return true;
|
|
113
|
+
if (!isSafeOwnedFile(paths.stateFile, expectedUid)) return true;
|
|
114
|
+
let state: Record<string, unknown>;
|
|
115
|
+
try {
|
|
116
|
+
state = JSON.parse(fs.readFileSync(paths.stateFile, "utf8")) as Record<string, unknown>;
|
|
117
|
+
} catch {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
if (state.schema !== "codex-birth-install-state/v1" || state.status !== "installed") return true;
|
|
121
|
+
if (state.hooksFile !== paths.hooksFile || state.helperDir !== paths.helperDir) return true;
|
|
122
|
+
const digest = (file: string): string | null => {
|
|
123
|
+
try {
|
|
124
|
+
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
if (typeof state.hooksSha256 !== "string" || digest(paths.hooksFile) !== state.hooksSha256) return true;
|
|
130
|
+
const members = state.helperFiles;
|
|
131
|
+
if (!Array.isArray(members)) return true;
|
|
132
|
+
// Exactly the closure the installer publishes: a short inventory would leave a member
|
|
133
|
+
// nothing compares, which is the same hole as not checking digests at all.
|
|
134
|
+
const named = members.map((raw) => (raw as Record<string, unknown>)?.path);
|
|
135
|
+
const expected = [
|
|
136
|
+
"codex-birth-launch.sh",
|
|
137
|
+
"meta-bridge-hook-codex.ts",
|
|
138
|
+
"lib/meta-session.ts",
|
|
139
|
+
"lib/native-push/codex-ws-client.ts",
|
|
140
|
+
"lib/session-id.js",
|
|
141
|
+
"entwurf-capabilities.json",
|
|
142
|
+
];
|
|
143
|
+
if (named.length !== expected.length) return true;
|
|
144
|
+
if (expected.some((name) => !named.includes(name))) return true;
|
|
145
|
+
if (named.some((name, index) => named.indexOf(name) !== index)) return true;
|
|
146
|
+
for (const raw of members) {
|
|
147
|
+
const member = raw as Record<string, unknown>;
|
|
148
|
+
const rel = member.path;
|
|
149
|
+
if (typeof rel !== "string" || rel.length === 0 || rel.startsWith("/") || rel.split("/").includes("..")) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (typeof member.sha256 !== "string" || digest(path.join(paths.helperDir, rel)) !== member.sha256) return true;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function birthMissing(paths: CodexUnitPaths, expectedUid: number): boolean {
|
|
158
|
+
const launcher = path.join(paths.helperDir, "codex-birth-launch.sh");
|
|
159
|
+
const directories = [
|
|
160
|
+
path.dirname(paths.hooksFile),
|
|
161
|
+
paths.helperDir,
|
|
162
|
+
path.join(paths.helperDir, "lib"),
|
|
163
|
+
path.join(paths.helperDir, "lib", "native-push"),
|
|
164
|
+
];
|
|
165
|
+
if (directories.some((dir) => !isSafeOwnedDir(dir, expectedUid))) return true;
|
|
166
|
+
if (!isSafeOwnedFile(paths.hooksFile, expectedUid)) return true;
|
|
167
|
+
const closure = [
|
|
168
|
+
[launcher, true],
|
|
169
|
+
[path.join(paths.helperDir, "meta-bridge-hook-codex.ts"), false],
|
|
170
|
+
[path.join(paths.helperDir, "lib", "meta-session.ts"), false],
|
|
171
|
+
[path.join(paths.helperDir, "lib", "native-push", "codex-ws-client.ts"), false],
|
|
172
|
+
[path.join(paths.helperDir, "lib", "session-id.js"), false],
|
|
173
|
+
[path.join(paths.helperDir, "entwurf-capabilities.json"), false],
|
|
174
|
+
] as const;
|
|
175
|
+
if (closure.some(([file, executable]) => !isSafeOwnedFile(file, expectedUid, executable))) return true;
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(fs.readFileSync(paths.hooksFile, "utf8")) as Record<string, unknown>;
|
|
178
|
+
const events = (parsed.hooks as Record<string, unknown> | undefined)?.SessionStart;
|
|
179
|
+
if (!Array.isArray(events) || events.length !== 1) return true;
|
|
180
|
+
const group = events[0] as Record<string, unknown>;
|
|
181
|
+
if ("matcher" in group || !Array.isArray(group.hooks) || group.hooks.length !== 1) return true;
|
|
182
|
+
const hook = group.hooks[0] as Record<string, unknown>;
|
|
183
|
+
if (Object.keys(hook).sort().join(",") !== "command,timeout,type") return true;
|
|
184
|
+
if (hook.type !== "command" || hook.command !== `'${launcher}'` || hook.timeout !== 30) return true;
|
|
185
|
+
} catch {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
return closureDriftedFromState(paths, expectedUid);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The vendor's own receipt for THIS declaration, read and never written. The key the vendor
|
|
193
|
+
* writes is `<declaration path>:<event>:<group>:<handler>`; the value must be a
|
|
194
|
+
* `trusted_hash` of the shape `sha256:<64 hex>`. What this asserts is that a receipt EXISTS
|
|
195
|
+
* for our declaration identity — never that the hash is correct, which only the vendor can
|
|
196
|
+
* say, and never by launching Codex to find out.
|
|
197
|
+
*/
|
|
198
|
+
function trustReceiptMissing(config: Record<string, unknown>, hooksFile: string): boolean {
|
|
199
|
+
const hooks = config.hooks;
|
|
200
|
+
if (hooks == null || typeof hooks !== "object" || Array.isArray(hooks)) return true;
|
|
201
|
+
const state = (hooks as Record<string, unknown>).state;
|
|
202
|
+
if (state == null || typeof state !== "object" || Array.isArray(state)) return true;
|
|
203
|
+
const entry = (state as Record<string, unknown>)[`${hooksFile}:session_start:0:0`];
|
|
204
|
+
if (entry == null || typeof entry !== "object" || Array.isArray(entry)) return true;
|
|
205
|
+
const digest = (entry as Record<string, unknown>).trusted_hash;
|
|
206
|
+
return typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readConfig(env: NodeJS.ProcessEnv): Record<string, unknown> | null {
|
|
210
|
+
try {
|
|
211
|
+
const text = fs.readFileSync(
|
|
212
|
+
path.join(env.CODEX_HOME?.trim() || path.join(env.HOME ?? "", ".codex"), "config.toml"),
|
|
213
|
+
"utf8",
|
|
214
|
+
);
|
|
215
|
+
const value = parse(text);
|
|
216
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function mcpMissing(config: Record<string, unknown>): boolean {
|
|
223
|
+
const servers = config.mcp_servers;
|
|
224
|
+
if (servers == null || typeof servers !== "object" || Array.isArray(servers)) return true;
|
|
225
|
+
const entry = (servers as Record<string, unknown>)["entwurf-bridge"];
|
|
226
|
+
if (entry == null || typeof entry !== "object" || Array.isArray(entry)) return true;
|
|
227
|
+
const server = entry as Record<string, unknown>;
|
|
228
|
+
if (Object.keys(server).sort().join(",") !== "command,env,env_vars") return true;
|
|
229
|
+
const envVars = server.env_vars;
|
|
230
|
+
if (
|
|
231
|
+
!Array.isArray(envVars) ||
|
|
232
|
+
envVars.length !== CODEX_MCP_ENV_VARS.length ||
|
|
233
|
+
envVars.some((name, index) => name !== CODEX_MCP_ENV_VARS[index])
|
|
234
|
+
)
|
|
235
|
+
return true;
|
|
236
|
+
const env = server.env;
|
|
237
|
+
if (env == null || typeof env !== "object" || Array.isArray(env)) return true;
|
|
238
|
+
const serverEnv = env as Record<string, unknown>;
|
|
239
|
+
return (
|
|
240
|
+
server.command !== "entwurf-bridge" ||
|
|
241
|
+
Object.keys(serverEnv).join(",") !== "ENTWURF_BRIDGE_NATIVE_HOST" ||
|
|
242
|
+
serverEnv.ENTWURF_BRIDGE_NATIVE_HOST !== "codex"
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function visibleIdentityMissing(config: Record<string, unknown>): boolean {
|
|
247
|
+
const tui = config.tui;
|
|
248
|
+
if (tui == null || typeof tui !== "object" || Array.isArray(tui)) return true;
|
|
249
|
+
const statusLine = (tui as Record<string, unknown>).status_line;
|
|
250
|
+
return !Array.isArray(statusLine) || !statusLine.includes("thread-title");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const DEFAULT_APP_SERVER_TIMEOUT_MS = 5_000;
|
|
254
|
+
|
|
255
|
+
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
const timer = setTimeout(() => reject(new Error("codex app-server exchange timed out")), timeoutMs);
|
|
258
|
+
promise.then(
|
|
259
|
+
(value) => {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
resolve(value);
|
|
262
|
+
},
|
|
263
|
+
(error) => {
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
reject(error);
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A bounded real initialize/liveness exchange over the app-server's own JSON-RPC. The inode
|
|
273
|
+
* check above only proves a socket FILE with the right owner and mode exists — a dead
|
|
274
|
+
* app-server's stale socket a new process has not reclaimed yet passes that check identically
|
|
275
|
+
* to a live one. Only a real connection plus a real `initialize` round trip distinguishes them.
|
|
276
|
+
* Any failure — refused/hung connection, a response that never lands within the bound, or a
|
|
277
|
+
* reply the transport itself rejects — answers identically: not live. This is deliberately not
|
|
278
|
+
* `probeCodexThread`: preflight asks "is the app-server there to talk to", never "is a specific
|
|
279
|
+
* thread loaded", so it carries no thread id and no retry/backoff policy.
|
|
280
|
+
*/
|
|
281
|
+
async function appServerIsLive(
|
|
282
|
+
socketPath: string,
|
|
283
|
+
openProtocol: CodexProtocolOpener,
|
|
284
|
+
timeoutMs: number,
|
|
285
|
+
): Promise<boolean> {
|
|
286
|
+
let protocol: CodexRpcProtocol;
|
|
287
|
+
const opening = openProtocol.open(socketPath);
|
|
288
|
+
try {
|
|
289
|
+
protocol = await withTimeout(opening, timeoutMs);
|
|
290
|
+
} catch {
|
|
291
|
+
// The bound expired (or the connect failed), but a slow open can still LAND afterwards.
|
|
292
|
+
// Nothing else holds that handle, so a late success would leave a live connection to the
|
|
293
|
+
// operator's app-server open for the rest of this process. Close it when it arrives —
|
|
294
|
+
// the answer stays "not live" either way; this only refuses to leak.
|
|
295
|
+
void opening.then(
|
|
296
|
+
(late) => {
|
|
297
|
+
try {
|
|
298
|
+
late.close();
|
|
299
|
+
} catch {
|
|
300
|
+
// best-effort: the socket may already be gone.
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
() => {
|
|
304
|
+
// The open failed; there is nothing to close.
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
await withTimeout(
|
|
311
|
+
protocol.request("initialize", {
|
|
312
|
+
clientInfo: { name: "entwurf-fresh-preflight", title: "entwurf-fresh-preflight", version: "0" },
|
|
313
|
+
capabilities: { experimentalApi: true, requestAttestation: false },
|
|
314
|
+
}),
|
|
315
|
+
timeoutMs,
|
|
316
|
+
);
|
|
317
|
+
return true;
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
} finally {
|
|
321
|
+
try {
|
|
322
|
+
protocol.close();
|
|
323
|
+
} catch {
|
|
324
|
+
// best-effort: the socket may already be gone.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Async because the app-server axis now performs a real bounded protocol exchange, not just an
|
|
331
|
+
* inode check (see `appServerIsLive`). Every other axis stays synchronous filesystem/config
|
|
332
|
+
* work; only the last `await` point can suspend.
|
|
333
|
+
*/
|
|
334
|
+
export async function codexFreshPreflight(
|
|
335
|
+
env: NodeJS.ProcessEnv,
|
|
336
|
+
deps: CodexPreflightDeps = {},
|
|
337
|
+
): Promise<CodexPreflightRejectReason | null> {
|
|
338
|
+
const paths = deps.unitPathsOverride ?? unitPaths(env);
|
|
339
|
+
// A relative root would make the declaration's launcher path — the very string the vendor
|
|
340
|
+
// keys its trust receipt to — depend on this process's cwd.
|
|
341
|
+
if (!path.isAbsolute(paths.hooksFile) || !path.isAbsolute(paths.helperDir) || !path.isAbsolute(paths.stateFile)) {
|
|
342
|
+
return "codex-birth-unit-missing";
|
|
343
|
+
}
|
|
344
|
+
if (birthMissing(paths, deps.operatorUid ?? process.getuid?.() ?? -1)) {
|
|
345
|
+
return "codex-birth-unit-missing";
|
|
346
|
+
}
|
|
347
|
+
const config = readConfig(env);
|
|
348
|
+
// The vendor receipt is a SEPARATE reject from our bytes: perfect bytes the vendor will
|
|
349
|
+
// not run and absent bytes are two different repairs, and folding them would send the
|
|
350
|
+
// operator to the installer for something only they can answer in their own Codex.
|
|
351
|
+
if (config === null || trustReceiptMissing(config, paths.hooksFile)) return "codex-birth-trust-missing";
|
|
352
|
+
if (mcpMissing(config)) return "codex-mcp-hand-missing";
|
|
353
|
+
if (visibleIdentityMissing(config)) return "codex-visible-identity-missing";
|
|
354
|
+
const socketPath = resolveCodexDefaultSocketPath(env);
|
|
355
|
+
const socket = (deps.checkSocket ?? checkCodexSocketFile)(socketPath);
|
|
356
|
+
if (!socket.ok) return "codex-app-server-unavailable";
|
|
357
|
+
const live = await appServerIsLive(
|
|
358
|
+
socketPath,
|
|
359
|
+
deps.openProtocol ?? realCodexProtocolOpener,
|
|
360
|
+
deps.appServerTimeoutMs ?? DEFAULT_APP_SERVER_TIMEOUT_MS,
|
|
361
|
+
);
|
|
362
|
+
return live ? null : "codex-app-server-unavailable";
|
|
363
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compaction-send-guard — whether a control-socket `send` may call `pi.sendMessage`.
|
|
3
|
+
*
|
|
4
|
+
* Pi 0.85.1 has no public `ExtensionContext.isCompacting()`. Compaction is non-idle
|
|
5
|
+
* (`AgentSession.isIdle` = no agent run AND not compacting) but `sendCustomMessage`
|
|
6
|
+
* does not refuse it. A non-streaming `triggerTurn:true` send starts `_runAgentPrompt`
|
|
7
|
+
* while `compact()` is rewriting the session tree — the field path that can make
|
|
8
|
+
* compaction "풀린다" and still return `delivered:true` (#111).
|
|
9
|
+
*
|
|
10
|
+
* Public facts the resident can see, without reaching into `AgentSession`:
|
|
11
|
+
* - `ctx.isIdle()`
|
|
12
|
+
* - `ctx.signal` — `agent.activeRun?.abortController.signal`, undefined when
|
|
13
|
+
* there is no low-level agent run. This is NOT `AgentSession.isStreaming`
|
|
14
|
+
* (`_isAgentRunActive`). After `agent.prompt()` returns, `_handlePostAgentRun`
|
|
15
|
+
* retry / auto-compaction / continuation keeps `_isAgentRunActive=true` while
|
|
16
|
+
* `ctx.signal` is already undefined.
|
|
17
|
+
* - `session_before_compact` / `session_compact` / `session_compact_failed`
|
|
18
|
+
*
|
|
19
|
+
* Terminal semantics, read from installed `agent-session.js`:
|
|
20
|
+
* - `session_before_compact` fires AFTER the abort controller is set (manual
|
|
21
|
+
* compact then awaits summarization auth — a real start-race window).
|
|
22
|
+
* - `session_compact` is NOT guaranteed (only if the saved compaction entry is
|
|
23
|
+
* found by summary-text match).
|
|
24
|
+
* - `session_compact_failed` fires on cancel / abort / fail.
|
|
25
|
+
* - `isIdle()` becoming true is the honest recovery for a missed success event.
|
|
26
|
+
*
|
|
27
|
+
* Auto-compaction can run inside `_handlePostAgentRun` while `_isAgentRunActive`
|
|
28
|
+
* stays true, so only the event flag can name that window `compacting`.
|
|
29
|
+
*
|
|
30
|
+
* Quiet unknown (`!idle && !hasAgentSignal && !armed`) is fail-closed as `busy`,
|
|
31
|
+
* not `compacting`: the public pair cannot tell manual-compact start race from
|
|
32
|
+
* branch summary, post-run retry, or continuation. Calling those `compacting`
|
|
33
|
+
* is a false claim. The start-race guarantee is still "do not call sendMessage".
|
|
34
|
+
*
|
|
35
|
+
* The predicate takes no send mode. Every mode is refused the same way.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export const COMPACTION_SEND_REJECT = "compacting" as const;
|
|
39
|
+
export const BUSY_SEND_REJECT = "busy" as const;
|
|
40
|
+
export type CompactionSendReject = typeof COMPACTION_SEND_REJECT | typeof BUSY_SEND_REJECT;
|
|
41
|
+
|
|
42
|
+
export type CompactionGuard = {
|
|
43
|
+
/** Armed by `session_before_compact`; disarmed by either terminal event or idle recovery. */
|
|
44
|
+
armed: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type CompactionSendFacts = {
|
|
48
|
+
idle: boolean;
|
|
49
|
+
/** `ctx.signal !== undefined`. Not `AgentSession.isStreaming`. */
|
|
50
|
+
hasAgentSignal: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function createCompactionGuard(): CompactionGuard {
|
|
54
|
+
return { armed: false };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function noteCompactionBefore(guard: CompactionGuard): void {
|
|
58
|
+
guard.armed = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function noteCompactionTerminal(guard: CompactionGuard): void {
|
|
62
|
+
guard.armed = false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `null` = admit (caller may `pi.sendMessage`).
|
|
67
|
+
* `"compacting"` = event-armed compaction; `"busy"` = quiet unknown non-idle.
|
|
68
|
+
* Either way: do not call `pi.sendMessage`; do not emit `delivered:true`.
|
|
69
|
+
*/
|
|
70
|
+
export function compactionSendReject(guard: CompactionGuard, facts: CompactionSendFacts): CompactionSendReject | null {
|
|
71
|
+
if (facts.idle) {
|
|
72
|
+
// Idle means Pi is neither in an agent run nor compacting. Also recovers a
|
|
73
|
+
// missed `session_compact` so a later ordinary stream is not stuck refused.
|
|
74
|
+
guard.armed = false;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (guard.armed) return COMPACTION_SEND_REJECT;
|
|
78
|
+
if (!facts.hasAgentSignal) return BUSY_SEND_REJECT;
|
|
79
|
+
return null; // live agent run — steer/followUp unchanged
|
|
80
|
+
}
|