@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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure message builders and final mailbox selection for Codex first-admission LIVE.
|
|
3
|
+
*
|
|
4
|
+
* Historical model launch/address reports are deliberately gone. The fixture now observes
|
|
5
|
+
* Pi toolCall↔toolResult rows, Codex thread/read MCP items, and raw callback envelopes directly.
|
|
6
|
+
* The one remaining mailbox message is Codex's final source-validated payload; token and sender
|
|
7
|
+
* are selected together, and duplicate exact candidates fail closed.
|
|
8
|
+
*
|
|
9
|
+
* The initial Pi receives its own outer sender_info after the user payload. The forwarded Codex
|
|
10
|
+
* instruction therefore rides between explicit BEGIN/END lines and the prompt says that neither
|
|
11
|
+
* marker nor the generated sender_info after END belongs to the forwarded bytes. Exact source-call
|
|
12
|
+
* arguments enforce that boundary; no suffix stripping repairs model drift after the fact.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Sender envelope line of a delivered meta-mailbox body: ` session: <garden id>`. */
|
|
16
|
+
const SENDER_LINE = /^\s*session:\s+(\S+)/m;
|
|
17
|
+
|
|
18
|
+
export function reportSender(body: string): string | null {
|
|
19
|
+
return SENDER_LINE.exec(body)?.[1] ?? null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The report whose token AND sender both match. Both halves are decided BEFORE the wait ends:
|
|
24
|
+
* a matching token from somebody else is skipped, not returned and then asserted on.
|
|
25
|
+
*/
|
|
26
|
+
export function selectReport(bodies: readonly string[], token: string, expectedSender: string): string | null {
|
|
27
|
+
const matching: string[] = [];
|
|
28
|
+
for (const body of bodies) {
|
|
29
|
+
if (!body.includes(token)) continue;
|
|
30
|
+
if (reportSender(body) !== expectedSender) continue;
|
|
31
|
+
matching.push(body);
|
|
32
|
+
}
|
|
33
|
+
if (matching.length > 1) throw new Error(`duplicate exact mailbox reports (${matching.length})`);
|
|
34
|
+
return matching[0] ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CodexInstructionInput {
|
|
38
|
+
waitToken: string;
|
|
39
|
+
finalToken: string;
|
|
40
|
+
callerGid: string;
|
|
41
|
+
piModel: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The payload the initial Pi forwards verbatim. It names only Codex's own steps. */
|
|
45
|
+
export function buildCodexInstruction(input: CodexInstructionInput): string {
|
|
46
|
+
return (
|
|
47
|
+
`${input.waitToken}\nThis is the addressed instruction from the initial Pi caller. Execute once, with no shell, ` +
|
|
48
|
+
"terminal, screen inspection, or retry:\n" +
|
|
49
|
+
`1. Call public mcp__entwurf_bridge__entwurf_fresh_call once with backend pi, model ${input.piModel}, ` +
|
|
50
|
+
// NO placement AND NO cwd are both CLAIMS rather than conveniences, and they are two
|
|
51
|
+
// different ones. The seat must come from this Codex's own pane title (#95 lane B); the
|
|
52
|
+
// DIRECTORY must come from this Codex's own record (#95 lane C), which is a fact only
|
|
53
|
+
// because its thread now opens where its pane is rather than where the app-server sits.
|
|
54
|
+
// Omitting the parameter is the whole point — passing one would prove neither.
|
|
55
|
+
'NO cwd parameter and NO placement parameter, and task "After your automatic callback succeeds, answer ACK and ' +
|
|
56
|
+
'stop. Do not open another sibling."\n' +
|
|
57
|
+
"2. Stop and wait for the Pi callback delivered into this Codex thread. Accept only a callback whose body " +
|
|
58
|
+
"repeats the exact nonce from your LAUNCH receipt and whose sender envelope names a garden id.\n" +
|
|
59
|
+
`3. Only after that match, call public mcp__entwurf_bridge__entwurf_v2 once with target ${input.callerGid}, ` +
|
|
60
|
+
"intent fire-and-forget, wants_reply false, and message:\n" +
|
|
61
|
+
`${input.finalToken}\nPI_LAUNCH_NONCE=<exact launch nonce>\nPI_CALLBACK_NONCE=<same exact nonce>\n` +
|
|
62
|
+
"PI_CALLBACK_FROM=<sender-envelope garden id>\nPI_SESSION_ID=<exact $session id from the LAUNCH receipt>\n" +
|
|
63
|
+
"PI_WINDOW_ID=<exact @window id from the LAUNCH receipt>\nDo not claim completion in prose."
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface InitialPiPhaseOneInput {
|
|
68
|
+
initialPiWaitToken: string;
|
|
69
|
+
codexWaitToken: string;
|
|
70
|
+
codexModel: string;
|
|
71
|
+
scratch: string;
|
|
72
|
+
codexInstruction: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One bounded forwarded payload; source arguments, not model prose, are its oracle. */
|
|
76
|
+
export function buildInitialPiPhaseOne(input: InitialPiPhaseOneInput): string {
|
|
77
|
+
return (
|
|
78
|
+
`${input.initialPiWaitToken}\nExecute this public-tool chain exactly once; do not use shell, terminal, screen text, or retries:\n` +
|
|
79
|
+
// NO placement on BOTH legs since #95 D1: an omitted seat is the caller's own session for
|
|
80
|
+
// every backend, so this Codex opens in S beside the Pi that called it — which is what
|
|
81
|
+
// puts its TUI somewhere the app-server's inherited `TMUX` does not point.
|
|
82
|
+
`1. Call entwurf_fresh_call with backend codex, model ${input.codexModel}, cwd ${input.scratch}, NO placement, and task ` +
|
|
83
|
+
`"After your required callback receipt, end the turn and wait passively for the addressed instruction containing ${input.codexWaitToken}. ` +
|
|
84
|
+
`Do not call shell, sleep, terminal, or any tool to wait."\n` +
|
|
85
|
+
"2. Stop and wait for the Codex callback. Accept only a callback carrying the exact nonce from the LAUNCH " +
|
|
86
|
+
"receipt; take the Codex garden id only from its sender envelope.\n" +
|
|
87
|
+
"3. Call entwurf_v2 once to that callback-derived Codex garden id with intent fire-and-forget, wants_reply false, " +
|
|
88
|
+
"and message set to the exact bytes strictly between the BEGIN/END marker lines below; the marker lines and " +
|
|
89
|
+
"the generated sender_info after END are not part of the message. Then STOP. Send nothing else and contact " +
|
|
90
|
+
"nobody else.\n" +
|
|
91
|
+
"----- BEGIN MESSAGE FOR THE CODEX SIBLING -----\n" +
|
|
92
|
+
input.codexInstruction +
|
|
93
|
+
"\n----- END MESSAGE FOR THE CODEX SIBLING -----"
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface CleanupStage {
|
|
98
|
+
label: string;
|
|
99
|
+
run: () => unknown | Promise<unknown>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run every teardown stage even when an earlier recovery or cleanup stage throws. A LIVE must
|
|
104
|
+
* report cleanup trouble, but that trouble must never prevent the later owned resources from
|
|
105
|
+
* being closed. Returned stage failures are labelled so the caller can fold them into its one
|
|
106
|
+
* final verdict without losing the original run failure.
|
|
107
|
+
*/
|
|
108
|
+
export async function runCleanupStages(stages: readonly CleanupStage[]): Promise<string[]> {
|
|
109
|
+
const failures: string[] = [];
|
|
110
|
+
for (const stage of stages) {
|
|
111
|
+
try {
|
|
112
|
+
const reported = await stage.run();
|
|
113
|
+
if (Array.isArray(reported)) {
|
|
114
|
+
for (const failure of reported) failures.push(`${stage.label}: ${failure}`);
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
failures.push(`${stage.label}: threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return failures;
|
|
121
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { launchReceiptWindows } from "./launch-receipt-windows.ts";
|
|
3
|
+
|
|
4
|
+
export type SourceToolStatus = "pending" | "completed" | "failed";
|
|
5
|
+
|
|
6
|
+
export interface SourceToolReceipt {
|
|
7
|
+
toolName: string;
|
|
8
|
+
arguments: Record<string, unknown>;
|
|
9
|
+
text: string;
|
|
10
|
+
status: SourceToolStatus;
|
|
11
|
+
isError: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ExpectedSourceToolCall {
|
|
15
|
+
toolName: string;
|
|
16
|
+
arguments: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Compare source-call arguments by MEANING for the one key whose absence already means something,
|
|
21
|
+
* and byte-exactly for every other.
|
|
22
|
+
*
|
|
23
|
+
* `entwurf_v2`'s `wants_reply` is optional and absent IS false — the schema says so
|
|
24
|
+
* (`mcp/entwurf-bridge/src/index.ts:474`, `z.boolean().optional().describe("… (default false)")`)
|
|
25
|
+
* and the decider enforces it (`pi-extensions/lib/entwurf-v2-decider.ts:270`,
|
|
26
|
+
* `input.wantsReply ?? false`). So a model that reads "wants_reply false" in its instruction and
|
|
27
|
+
* simply omits the parameter has obeyed the instruction; nothing about the dispatch differs.
|
|
28
|
+
*
|
|
29
|
+
* `[측정 2026-09-16]` this cost a LIVE run at assertion 43: the initial Pi called `entwurf_v2`
|
|
30
|
+
* with byte-identical `target`, `intent` and `message` and no `wants_reply` key, and the
|
|
31
|
+
* `isDeepStrictEqual` oracle called it drift. The gate was reading a serialization shape where
|
|
32
|
+
* its claim is about the dispatch. Only THIS key is normalized — a `wants_reply: true` still
|
|
33
|
+
* mismatches an expected `false`, and `target`/`intent`/`message`/`placement`/`cwd` stay exact,
|
|
34
|
+
* because for those a difference is a difference.
|
|
35
|
+
*/
|
|
36
|
+
function normalizeSourceArguments(args: Record<string, unknown>): Record<string, unknown> {
|
|
37
|
+
if (!("wants_reply" in args)) return { ...args, wants_reply: false };
|
|
38
|
+
return args;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Arguments agree once the schema default above is filled in. */
|
|
42
|
+
export function sourceArgumentsMatch(expected: Record<string, unknown>, actual: Record<string, unknown>): boolean {
|
|
43
|
+
return isDeepStrictEqual(normalizeSourceArguments(expected), normalizeSourceArguments(actual));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Select one source call in its backend/target scope, rejecting drift, errors, and duplicates. */
|
|
47
|
+
export function selectExactSourceToolReceipt(
|
|
48
|
+
receipts: readonly SourceToolReceipt[],
|
|
49
|
+
toolName: string,
|
|
50
|
+
expectedArguments: Record<string, unknown>,
|
|
51
|
+
label: string,
|
|
52
|
+
): SourceToolReceipt | null {
|
|
53
|
+
const scopeKey = toolName === "entwurf_v2" ? "target" : null;
|
|
54
|
+
const matchingScope = receipts.filter(
|
|
55
|
+
(receipt) =>
|
|
56
|
+
receipt.toolName === toolName &&
|
|
57
|
+
(scopeKey === null || receipt.arguments[scopeKey] === expectedArguments[scopeKey]),
|
|
58
|
+
);
|
|
59
|
+
if (matchingScope.length > 1) throw new Error(`${label}: duplicate source tool calls (${matchingScope.length})`);
|
|
60
|
+
const receipt = matchingScope[0] ?? null;
|
|
61
|
+
if (receipt === null) return null;
|
|
62
|
+
if (!sourceArgumentsMatch(expectedArguments, receipt.arguments))
|
|
63
|
+
throw new Error(
|
|
64
|
+
`${label}: source tool arguments differ\nexpected=${JSON.stringify(expectedArguments)}\nactual=${JSON.stringify(receipt.arguments)}`,
|
|
65
|
+
);
|
|
66
|
+
if (receipt.status === "pending") return null;
|
|
67
|
+
if (receipt.status === "failed") throw new Error(`${label}: exact source tool result failed: ${receipt.text}`);
|
|
68
|
+
return receipt;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function assertSourceCallScopes(
|
|
72
|
+
receipts: readonly SourceToolReceipt[],
|
|
73
|
+
toolName: string,
|
|
74
|
+
scopeKey: string,
|
|
75
|
+
allowed: readonly unknown[],
|
|
76
|
+
label: string,
|
|
77
|
+
): void {
|
|
78
|
+
for (const receipt of receipts) {
|
|
79
|
+
if (receipt.toolName !== toolName) continue;
|
|
80
|
+
if (!allowed.includes(receipt.arguments[scopeKey]))
|
|
81
|
+
throw new Error(`${label}: unexpected ${scopeKey} ${JSON.stringify(receipt.arguments[scopeKey])}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Final exact-once audit over every source-observed Entwurf role in one smoke citizen. */
|
|
86
|
+
export function assertExactSourceToolCalls(
|
|
87
|
+
receipts: readonly SourceToolReceipt[],
|
|
88
|
+
expected: readonly ExpectedSourceToolCall[],
|
|
89
|
+
label: string,
|
|
90
|
+
): void {
|
|
91
|
+
if (receipts.length !== expected.length)
|
|
92
|
+
throw new Error(`${label}: source call count differs: expected=${expected.length} actual=${receipts.length}`);
|
|
93
|
+
const remaining = [...expected];
|
|
94
|
+
for (const receipt of receipts) {
|
|
95
|
+
if (receipt.status !== "completed") throw new Error(`${label}: ${receipt.toolName} remained ${receipt.status}`);
|
|
96
|
+
const index = remaining.findIndex(
|
|
97
|
+
(candidate) =>
|
|
98
|
+
candidate.toolName === receipt.toolName && sourceArgumentsMatch(candidate.arguments, receipt.arguments),
|
|
99
|
+
);
|
|
100
|
+
if (index < 0)
|
|
101
|
+
throw new Error(`${label}: unexpected source call ${receipt.toolName} ${JSON.stringify(receipt.arguments)}`);
|
|
102
|
+
remaining.splice(index, 1);
|
|
103
|
+
}
|
|
104
|
+
if (remaining.length > 0) throw new Error(`${label}: missing source calls ${JSON.stringify(remaining)}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The only production leaf that turns joined source receipts into cleanup window authority. */
|
|
108
|
+
export function sourceCleanupWindows(receipts: readonly SourceToolReceipt[]): string[] {
|
|
109
|
+
const windows: string[] = [];
|
|
110
|
+
for (const receipt of receipts) {
|
|
111
|
+
if (receipt.toolName !== "entwurf_fresh_call" || receipt.status !== "completed" || receipt.isError) continue;
|
|
112
|
+
const backend = receipt.arguments.backend;
|
|
113
|
+
if (backend !== "pi" && backend !== "codex") continue;
|
|
114
|
+
windows.push(...launchReceiptWindows(receipt.text, backend));
|
|
115
|
+
}
|
|
116
|
+
return windows;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function textContent(value: unknown): string {
|
|
120
|
+
if (!Array.isArray(value)) return "";
|
|
121
|
+
return value
|
|
122
|
+
.map((block) => {
|
|
123
|
+
if (typeof block !== "object" || block === null) return "";
|
|
124
|
+
const text = (block as { text?: unknown }).text;
|
|
125
|
+
return typeof text === "string" ? text : "";
|
|
126
|
+
})
|
|
127
|
+
.join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function looksLikeIncompleteJsonAppend(source: string): boolean {
|
|
131
|
+
let depth = 0;
|
|
132
|
+
let inString = false;
|
|
133
|
+
let escaped = false;
|
|
134
|
+
for (const character of source) {
|
|
135
|
+
if (inString) {
|
|
136
|
+
if (escaped) escaped = false;
|
|
137
|
+
else if (character === "\\") escaped = true;
|
|
138
|
+
else if (character === '"') inString = false;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (character === '"') inString = true;
|
|
142
|
+
else if (character === "{" || character === "[") depth++;
|
|
143
|
+
else if (character === "}" || character === "]") depth--;
|
|
144
|
+
if (depth < 0) return false;
|
|
145
|
+
}
|
|
146
|
+
return inString || depth > 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve a source transcript path whose own record may not carry it yet. The record stays the
|
|
151
|
+
* sole authority: an empty cache re-reads it, so a path the runtime writes AFTER callback
|
|
152
|
+
* correlation is still observed instead of being frozen out of the run. Measured — a pi record
|
|
153
|
+
* gained its `transcriptPath` 34s after correlation, and a one-shot read made every later
|
|
154
|
+
* source observation permanently empty. A reader that throws is fail-loud; only "the record has
|
|
155
|
+
* no path yet" is pending. Model prose and tmux listings are never a substitute source.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveSourceTranscriptPath(cached: string, readRecordPath: () => string | null | undefined): string {
|
|
158
|
+
if (cached !== "") return cached;
|
|
159
|
+
return readRecordPath() ?? "";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Parse complete JSONL rows; only one structurally incomplete final append is pending. */
|
|
163
|
+
export function parseJsonLines(source: string): unknown[] {
|
|
164
|
+
const lines = source.split("\n");
|
|
165
|
+
const entries: unknown[] = [];
|
|
166
|
+
for (const [index, line] of lines.entries()) {
|
|
167
|
+
if (line.trim() === "") continue;
|
|
168
|
+
try {
|
|
169
|
+
entries.push(JSON.parse(line));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const isIncompleteLastAppend =
|
|
172
|
+
index === lines.length - 1 && !source.endsWith("\n") && looksLikeIncompleteJsonAppend(line);
|
|
173
|
+
if (isIncompleteLastAppend) return entries;
|
|
174
|
+
throw new Error(
|
|
175
|
+
`invalid complete JSONL row ${index + 1}: ${error instanceof Error ? error.message : String(error)}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return entries;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Join Pi's own toolCall and toolResult rows by native id, retaining pending calls. */
|
|
183
|
+
export function piSourceToolReceipts(entries: readonly unknown[]): SourceToolReceipt[] {
|
|
184
|
+
const calls = new Map<string, { toolName: string; arguments: Record<string, unknown> }>();
|
|
185
|
+
const results = new Map<string, { toolName: string; text: string; isError: boolean }>();
|
|
186
|
+
for (const raw of entries) {
|
|
187
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
188
|
+
const entry = raw as { type?: unknown; message?: unknown };
|
|
189
|
+
if (entry.type !== "message" || typeof entry.message !== "object" || entry.message === null) continue;
|
|
190
|
+
const message = entry.message as {
|
|
191
|
+
role?: unknown;
|
|
192
|
+
content?: unknown;
|
|
193
|
+
toolCallId?: unknown;
|
|
194
|
+
toolName?: unknown;
|
|
195
|
+
isError?: unknown;
|
|
196
|
+
};
|
|
197
|
+
if (message.role === "assistant" && Array.isArray(message.content)) {
|
|
198
|
+
for (const rawBlock of message.content) {
|
|
199
|
+
if (typeof rawBlock !== "object" || rawBlock === null) continue;
|
|
200
|
+
const block = rawBlock as { type?: unknown; id?: unknown; name?: unknown; arguments?: unknown };
|
|
201
|
+
if (block.type !== "toolCall") continue;
|
|
202
|
+
if (
|
|
203
|
+
typeof block.id !== "string" ||
|
|
204
|
+
typeof block.name !== "string" ||
|
|
205
|
+
typeof block.arguments !== "object" ||
|
|
206
|
+
block.arguments === null ||
|
|
207
|
+
Array.isArray(block.arguments)
|
|
208
|
+
)
|
|
209
|
+
throw new Error("malformed Pi toolCall row");
|
|
210
|
+
if (calls.has(block.id)) throw new Error(`duplicate Pi toolCall id ${block.id}`);
|
|
211
|
+
calls.set(block.id, {
|
|
212
|
+
toolName: block.name,
|
|
213
|
+
arguments: block.arguments as Record<string, unknown>,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (message.role === "toolResult") {
|
|
218
|
+
if (typeof message.toolCallId !== "string" || typeof message.toolName !== "string")
|
|
219
|
+
throw new Error("malformed Pi toolResult row");
|
|
220
|
+
if (typeof message.isError !== "boolean")
|
|
221
|
+
throw new Error(`Pi toolResult ${message.toolCallId} lacks explicit boolean isError`);
|
|
222
|
+
if (results.has(message.toolCallId)) throw new Error(`duplicate Pi toolResult id ${message.toolCallId}`);
|
|
223
|
+
results.set(message.toolCallId, {
|
|
224
|
+
toolName: message.toolName,
|
|
225
|
+
text: textContent(message.content),
|
|
226
|
+
isError: message.isError,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const id of results.keys()) {
|
|
231
|
+
if (!calls.has(id)) throw new Error(`unmatched Pi toolResult id ${id}`);
|
|
232
|
+
}
|
|
233
|
+
const joined: SourceToolReceipt[] = [];
|
|
234
|
+
for (const [id, call] of calls) {
|
|
235
|
+
const result = results.get(id);
|
|
236
|
+
if (result === undefined) {
|
|
237
|
+
joined.push({ ...call, text: "", status: "pending", isError: false });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (result.toolName !== call.toolName)
|
|
241
|
+
throw new Error(`Pi tool name drift for ${id}: call=${call.toolName} result=${result.toolName}`);
|
|
242
|
+
joined.push({
|
|
243
|
+
...call,
|
|
244
|
+
text: result.text,
|
|
245
|
+
status: result.isError ? "failed" : "completed",
|
|
246
|
+
isError: result.isError,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return joined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function codexThreadIsTerminal(thread: unknown): boolean {
|
|
253
|
+
if (typeof thread !== "object" || thread === null || !Array.isArray((thread as { turns?: unknown }).turns))
|
|
254
|
+
throw new Error("malformed Codex thread/read turns");
|
|
255
|
+
const turns = (thread as { turns: unknown[] }).turns;
|
|
256
|
+
if (turns.length === 0) return false;
|
|
257
|
+
for (const rawTurn of turns) {
|
|
258
|
+
if (typeof rawTurn !== "object" || rawTurn === null) throw new Error("malformed Codex thread/read turn");
|
|
259
|
+
if (!Array.isArray((rawTurn as { items?: unknown }).items)) throw new Error("malformed Codex thread/read items");
|
|
260
|
+
const status = (rawTurn as { status?: unknown }).status;
|
|
261
|
+
if (status === "inProgress") return false;
|
|
262
|
+
if (status === "failed" || status === "interrupted") throw new Error(`Codex turn ended ${status}, not completed`);
|
|
263
|
+
if (status !== "completed") throw new Error(`unknown Codex turn status ${String(status)}`);
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Read Entwurf MCP calls from Codex thread/read, retaining vendor inProgress state. */
|
|
269
|
+
export function codexSourceToolReceipts(thread: unknown): SourceToolReceipt[] {
|
|
270
|
+
if (typeof thread !== "object" || thread === null) return [];
|
|
271
|
+
const turns = (thread as { turns?: unknown }).turns;
|
|
272
|
+
if (!Array.isArray(turns)) return [];
|
|
273
|
+
const found: SourceToolReceipt[] = [];
|
|
274
|
+
const ids = new Set<string>();
|
|
275
|
+
for (const rawTurn of turns) {
|
|
276
|
+
if (typeof rawTurn !== "object" || rawTurn === null) continue;
|
|
277
|
+
const items = (rawTurn as { items?: unknown }).items;
|
|
278
|
+
if (!Array.isArray(items)) continue;
|
|
279
|
+
for (const rawItem of items) {
|
|
280
|
+
if (typeof rawItem !== "object" || rawItem === null) continue;
|
|
281
|
+
const item = rawItem as {
|
|
282
|
+
type?: unknown;
|
|
283
|
+
id?: unknown;
|
|
284
|
+
server?: unknown;
|
|
285
|
+
tool?: unknown;
|
|
286
|
+
status?: unknown;
|
|
287
|
+
arguments?: unknown;
|
|
288
|
+
result?: unknown;
|
|
289
|
+
error?: unknown;
|
|
290
|
+
};
|
|
291
|
+
if (item.type !== "mcpToolCall" || item.server !== "entwurf-bridge") continue;
|
|
292
|
+
if (typeof item.id !== "string" || typeof item.tool !== "string")
|
|
293
|
+
throw new Error("malformed Codex entwurf MCP call identity");
|
|
294
|
+
if (ids.has(item.id)) throw new Error(`duplicate Codex MCP tool-call id ${item.id}`);
|
|
295
|
+
ids.add(item.id);
|
|
296
|
+
if (typeof item.arguments !== "object" || item.arguments === null || Array.isArray(item.arguments))
|
|
297
|
+
throw new Error(`malformed Codex MCP arguments for ${item.id}`);
|
|
298
|
+
const result = typeof item.result === "object" && item.result !== null ? item.result : null;
|
|
299
|
+
if (item.status === "inProgress") {
|
|
300
|
+
if (result !== null || item.error !== null)
|
|
301
|
+
throw new Error(`malformed inProgress Codex MCP state for ${item.id}`);
|
|
302
|
+
found.push({
|
|
303
|
+
toolName: item.tool,
|
|
304
|
+
arguments: item.arguments as Record<string, unknown>,
|
|
305
|
+
text: "",
|
|
306
|
+
status: "pending",
|
|
307
|
+
isError: false,
|
|
308
|
+
});
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (item.status !== "completed" && item.status !== "failed")
|
|
312
|
+
throw new Error(`unknown Codex MCP status ${String(item.status)} for ${item.id}`);
|
|
313
|
+
const completed = item.status === "completed" && item.error === null && result !== null;
|
|
314
|
+
found.push({
|
|
315
|
+
toolName: item.tool,
|
|
316
|
+
arguments: item.arguments as Record<string, unknown>,
|
|
317
|
+
text: textContent((result as { content?: unknown } | null)?.content),
|
|
318
|
+
status: completed ? "completed" : "failed",
|
|
319
|
+
isError: !completed,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return found;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function exactSenderInfoSuffix(text: string, nonce: string): string | null {
|
|
327
|
+
const prefix = `${nonce}\n\n<sender_info>`;
|
|
328
|
+
const suffix = "</sender_info>";
|
|
329
|
+
if (!text.startsWith(prefix) || !text.endsWith(suffix)) return null;
|
|
330
|
+
const json = text.slice(prefix.length, -suffix.length);
|
|
331
|
+
if (json.includes("\n") || json.includes("<sender_info>")) return null;
|
|
332
|
+
try {
|
|
333
|
+
const sender = JSON.parse(json) as { sessionId?: unknown };
|
|
334
|
+
return typeof sender.sessionId === "string" ? sender.sessionId : null;
|
|
335
|
+
} catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Exact callback in Pi's own transcript: nonce plus one terminal generated sender envelope. */
|
|
341
|
+
export function piCallbackSenders(entries: readonly unknown[], nonce: string): string[] {
|
|
342
|
+
const found: string[] = [];
|
|
343
|
+
for (const raw of entries) {
|
|
344
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
345
|
+
const entry = raw as { type?: unknown; customType?: unknown; content?: unknown };
|
|
346
|
+
if (entry.type !== "custom_message" || entry.customType !== "entwurf-message" || typeof entry.content !== "string")
|
|
347
|
+
continue;
|
|
348
|
+
const sender = exactSenderInfoSuffix(entry.content, nonce);
|
|
349
|
+
if (sender !== null) found.push(sender);
|
|
350
|
+
}
|
|
351
|
+
return found;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export interface MetaMailboxBody {
|
|
355
|
+
sender: string;
|
|
356
|
+
payload: string;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Strip exactly formatMetaMailboxBody's anchored outer frame and one terminal newline. */
|
|
360
|
+
export function parseMetaMailboxBody(text: string): MetaMailboxBody | null {
|
|
361
|
+
const frame =
|
|
362
|
+
/^\[entwurf received ⟵\]\n {2}from: {8}[^\n]*\n {2}session: {5}(\S+)(?: [^\n]*)?\n {2}at: {10}[^\n]+\n {2}wants reply: (?:yes|no)\n────────────────────────────────────────\n/;
|
|
363
|
+
const match = frame.exec(text);
|
|
364
|
+
if (match === null || !text.endsWith("\n")) return null;
|
|
365
|
+
return { sender: match[1] as string, payload: text.slice(match[0].length, -1) };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export interface FinalMailboxValidation {
|
|
369
|
+
accepted: boolean;
|
|
370
|
+
reason: string;
|
|
371
|
+
parsed: MetaMailboxBody | null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function validateFinalMailboxBody(
|
|
375
|
+
body: string,
|
|
376
|
+
expectedSender: string,
|
|
377
|
+
expectedPayload: string,
|
|
378
|
+
): FinalMailboxValidation {
|
|
379
|
+
const parsed = parseMetaMailboxBody(body);
|
|
380
|
+
if (parsed === null) return { accepted: false, reason: "rejected: invalid outer frame", parsed: null };
|
|
381
|
+
if (parsed.sender !== expectedSender)
|
|
382
|
+
return { accepted: false, reason: `rejected: sender differs from ${expectedSender}`, parsed };
|
|
383
|
+
if (parsed.payload !== expectedPayload)
|
|
384
|
+
return { accepted: false, reason: "rejected: payload differs from source-call argument", parsed };
|
|
385
|
+
return { accepted: true, reason: "accepted: exact outer frame, sender, and source-call payload", parsed };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export interface MailboxCallback {
|
|
389
|
+
body: string;
|
|
390
|
+
sender: string;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Exact callback bodies read by the record-backed fixture's mailbox. */
|
|
394
|
+
export function mailboxCallbacks(bodies: readonly string[], nonce: string): MailboxCallback[] {
|
|
395
|
+
const found: MailboxCallback[] = [];
|
|
396
|
+
for (const body of bodies) {
|
|
397
|
+
const parsed = parseMetaMailboxBody(body);
|
|
398
|
+
if (parsed !== null && parsed.payload === nonce) found.push({ body, sender: parsed.sender });
|
|
399
|
+
}
|
|
400
|
+
return found;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Exact callback in a Codex thread/read userMessage item. */
|
|
404
|
+
export function codexCallbackSenders(thread: unknown, nonce: string): string[] {
|
|
405
|
+
if (typeof thread !== "object" || thread === null) return [];
|
|
406
|
+
const turns = (thread as { turns?: unknown }).turns;
|
|
407
|
+
if (!Array.isArray(turns)) return [];
|
|
408
|
+
const found: string[] = [];
|
|
409
|
+
for (const rawTurn of turns) {
|
|
410
|
+
if (typeof rawTurn !== "object" || rawTurn === null) continue;
|
|
411
|
+
const items = (rawTurn as { items?: unknown }).items;
|
|
412
|
+
if (!Array.isArray(items)) continue;
|
|
413
|
+
for (const rawItem of items) {
|
|
414
|
+
if (typeof rawItem !== "object" || rawItem === null || (rawItem as { type?: unknown }).type !== "userMessage")
|
|
415
|
+
continue;
|
|
416
|
+
const content = (rawItem as { content?: unknown }).content;
|
|
417
|
+
if (!Array.isArray(content) || content.length !== 1) continue;
|
|
418
|
+
const block = content[0];
|
|
419
|
+
if (typeof block !== "object" || block === null || typeof (block as { text?: unknown }).text !== "string")
|
|
420
|
+
continue;
|
|
421
|
+
const parsed = parseMetaMailboxBody((block as { text: string }).text);
|
|
422
|
+
if (parsed !== null && parsed.payload === nonce) found.push(parsed.sender);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return found;
|
|
426
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one parser that turns a LAUNCH receipt back into the tmux window handle it named.
|
|
3
|
+
*
|
|
4
|
+
* It exists for ONE job: when a travelling receipt never arrives, the visible window a
|
|
5
|
+
* sibling opened would be orphaned, because cleanup only ever kills handles the smoke
|
|
6
|
+
* recorded. The handle is still written down — in the caller's own transcript and in the
|
|
7
|
+
* Codex thread's own tool-call result — so recovery reads it from there instead of
|
|
8
|
+
* scanning tmux inventory or guessing.
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS IS LINE-ORIENTED AND NOT A CHARACTER WINDOW. `renderFreshCall` writes one block
|
|
11
|
+
* whose `backend:` and `window:` lines are separated by a VARIABLE number of optional lines
|
|
12
|
+
* (cwd, seat). A character bound between them is a guess about how long those lines are, and
|
|
13
|
+
* the guess was already nearly spent: `[측정]` a rendered receipt carrying both cwd and seat
|
|
14
|
+
* spans 344 escaped characters, against the 400-character bound this replaced. A longer
|
|
15
|
+
* project path would have silently recovered NOTHING. The boundary here is the receipt's own
|
|
16
|
+
* line structure, which does not have a length.
|
|
17
|
+
*
|
|
18
|
+
* Transcripts and tool results carry the receipt JSON-escaped, so a line ends at a real
|
|
19
|
+
* newline OR at a literal backslash-n. Both are separators.
|
|
20
|
+
*
|
|
21
|
+
* It stays anchored on the receipt: a `window:` line with no `backend:` line before it is
|
|
22
|
+
* not a window this run opened, and a receipt for the OTHER backend is never mistaken for
|
|
23
|
+
* this one's.
|
|
24
|
+
*/
|
|
25
|
+
const LINE = /\r?\n|\\n/;
|
|
26
|
+
const BACKEND_LINE = /^\s*backend:\s+([a-z-]+)\b/;
|
|
27
|
+
const WINDOW_LINE = /^\s*window:\s+(@\d+)\b/;
|
|
28
|
+
|
|
29
|
+
export function launchReceiptWindows(text: string, backend: string): string[] {
|
|
30
|
+
const found: string[] = [];
|
|
31
|
+
let open: string | null = null;
|
|
32
|
+
for (const line of text.split(LINE)) {
|
|
33
|
+
const backendLine = BACKEND_LINE.exec(line);
|
|
34
|
+
if (backendLine !== null) {
|
|
35
|
+
open = backendLine[1] ?? null;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const windowLine = WINDOW_LINE.exec(line);
|
|
39
|
+
if (windowLine === null) continue;
|
|
40
|
+
if (open === backend) found.push(windowLine[1] as string);
|
|
41
|
+
// One receipt names one window: close the block so a later stray line cannot inherit
|
|
42
|
+
// this backend.
|
|
43
|
+
open = null;
|
|
44
|
+
}
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux-coordinate-row — parse ONE positional TSV row out of `tmux display-message -p`.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS, measured. The Codex first-admission LIVE asked tmux for four coordinates in
|
|
5
|
+
* one row and split the result with `.trim().split("\t")`. On tmux 3.6a `#{server_pid}` answers
|
|
6
|
+
* with an EMPTY string, so the row arrived as `"\t$150\t@342\t%342\n"` — and `.trim()` ate the
|
|
7
|
+
* leading tab along with the newline. Every field then shifted one place left: the session id
|
|
8
|
+
* was read as the server pid, the window id as the session id, and the pane id vanished. The
|
|
9
|
+
* gate failed, which was right, but it failed at the wrong coordinate and blamed the host for a
|
|
10
|
+
* field the format never filled.
|
|
11
|
+
*
|
|
12
|
+
* So two rules, and the second one is the reason this is a separate leaf:
|
|
13
|
+
*
|
|
14
|
+
* 1. Exactly ONE terminal newline is removed — `\n` or `\r\n`, and only at the end. Anything
|
|
15
|
+
* else (an embedded newline, a second trailing one) means the row is not the single line
|
|
16
|
+
* the caller asked for, and is refused rather than silently reshaped.
|
|
17
|
+
* 2. EMPTY FIELDS ARE KEPT, leading and trailing alike. A format key that answers with
|
|
18
|
+
* nothing must fail at its own position — "the server pid is empty" is a fact the caller
|
|
19
|
+
* can act on; "everything after it moved" is a fact nobody can.
|
|
20
|
+
*
|
|
21
|
+
* The field COUNT is the caller's contract, so it is passed in and enforced here: a row with
|
|
22
|
+
* more or fewer columns than the format asked for is malformed, never truncated to fit.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export class TmuxRowError extends Error {}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param raw the exact stdout of a single `display-message -p` invocation
|
|
29
|
+
* @param fieldCount how many `\t`-separated fields the caller's format string asked for
|
|
30
|
+
*/
|
|
31
|
+
export function parseTmuxRow(raw: string, fieldCount: number): string[] {
|
|
32
|
+
if (!Number.isInteger(fieldCount) || fieldCount < 1) {
|
|
33
|
+
throw new TmuxRowError(`fieldCount must be a positive integer, got ${String(fieldCount)}`);
|
|
34
|
+
}
|
|
35
|
+
let line = raw;
|
|
36
|
+
if (line.endsWith("\r\n")) line = line.slice(0, -2);
|
|
37
|
+
else if (line.endsWith("\n")) line = line.slice(0, -1);
|
|
38
|
+
if (line.includes("\n") || line.includes("\r")) {
|
|
39
|
+
throw new TmuxRowError(
|
|
40
|
+
`tmux answered with more than one line (${JSON.stringify(raw)}); a positional row must be exactly one`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const fields = line.split("\t");
|
|
44
|
+
if (fields.length !== fieldCount) {
|
|
45
|
+
throw new TmuxRowError(
|
|
46
|
+
`tmux answered ${fields.length} field(s), the format asked for ${fieldCount} (${JSON.stringify(raw)})`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return fields;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The server pid key that actually answers. `[host]` measured on tmux 3.6a: `#{pid}` returns
|
|
54
|
+
* 5772, the same pid the `$TMUX` tuple carries, while `#{server_pid}` returns an empty string.
|
|
55
|
+
* Pinned as one literal so the LIVE inspector and its deterministic oracle cannot drift apart.
|
|
56
|
+
*/
|
|
57
|
+
export const TMUX_COORDINATE_FORMAT = "#{pid}\t#{session_id}\t#{window_id}\t#{pane_id}";
|
|
58
|
+
export const TMUX_COORDINATE_FIELDS = 4;
|
|
@@ -442,7 +442,12 @@ async function inspectNativePushCitizens(storeDir: string): Promise<QuiesceViola
|
|
|
442
442
|
try {
|
|
443
443
|
const probe = await resolveNativePushAdapter(identity.backend).probe(identity.nativeSessionId);
|
|
444
444
|
status = probe.status;
|
|
445
|
-
reason =
|
|
445
|
+
reason =
|
|
446
|
+
probe.status === "alive"
|
|
447
|
+
? probe.route.backend === "antigravity"
|
|
448
|
+
? `route ${probe.route.lsAddress}`
|
|
449
|
+
: `socket ${probe.route.socketPath}`
|
|
450
|
+
: probe.reason;
|
|
446
451
|
} catch (err) {
|
|
447
452
|
// A probe that cannot run is not a dead conversation.
|
|
448
453
|
violations.push({
|