@junghanacs/entwurf 0.21.0 → 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 +2 -2
- package/BASELINE.md +3 -1
- package/CHANGELOG.md +278 -0
- package/DELIVERY.md +156 -26
- package/README.md +64 -13
- package/VERIFY.md +67 -11
- package/docs/external-mcp-host.md +16 -6
- package/docs/setup-clean-host.md +63 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +40 -16
- 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 +194 -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/mux-fresh-call.js +160 -25
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
- package/mcp/entwurf-bridge/src/index.ts +50 -16
- package/mcp/entwurf-bridge/tsconfig.build.json +9 -0
- package/package.json +2 -2
- package/pi-extensions/entwurf-control.ts +4 -4
- package/pi-extensions/lib/codex-caller-seat.ts +204 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +218 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
- package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
- package/pi-extensions/lib/mux-fresh-call.ts +196 -37
- package/run.sh +127 -3
- package/scripts/check-codex-app-server-launch.ts +445 -0
- package/scripts/check-entwurf-v2-production.ts +42 -1
- package/scripts/check-entwurf-v2-send.ts +26 -7
- package/scripts/check-gate-qualification.ts +4 -2
- package/scripts/check-mux-launch-tmux.ts +331 -35
- package/scripts/codex-app-server-launch.sh +275 -0
- package/scripts/codex-socket-path.ts +33 -0
- package/scripts/codex-terminal-title-config.py +500 -0
- package/scripts/codex_toml_io.py +121 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +11 -3
- package/scripts/lib/codex-fresh-source-receipts.ts +29 -2
- 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 +3 -3
- package/scripts/mutants/mux-fresh-call.json +86 -14
- package/scripts/mutants/v2-surface.json +22 -0
- package/scripts/smoke-codex-config-state.sh +192 -3
- package/scripts/smoke-codex-fresh-live.ts +277 -37
- package/scripts/smoke-entwurf-chain-live.ts +50 -0
- package/scripts/smoke-setup-verdict.sh +13 -11
|
@@ -64,7 +64,7 @@ import * as process from "node:process";
|
|
|
64
64
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
65
65
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
66
66
|
import { z } from "zod";
|
|
67
|
-
import { codexFreshPreflight } from "../../../pi-extensions/lib/codex-fresh-preflight.js";
|
|
67
|
+
import { codexCallerFreshPreflight, codexFreshPreflight } from "../../../pi-extensions/lib/codex-fresh-preflight.js";
|
|
68
68
|
import { controlSocketPathIn, defaultControlSocketDir } from "../../../pi-extensions/lib/control-socket-path.js";
|
|
69
69
|
import { resolveMailboxReceiverFacts } from "../../../pi-extensions/lib/entwurf-deliverability.js";
|
|
70
70
|
import { listEntwurfFacts } from "../../../pi-extensions/lib/entwurf-fact-provider.js";
|
|
@@ -270,7 +270,7 @@ async function resolveAuthoritativeSender(context, cwd = process.cwd()) {
|
|
|
270
270
|
if (!selected)
|
|
271
271
|
return null;
|
|
272
272
|
if (codex && codex.identity.gardenId === selected.id) {
|
|
273
|
-
return buildMetaSenderEnvelope(codex.identity, codex.identity.cwd);
|
|
273
|
+
return { ...(await buildMetaSenderEnvelope(codex.identity, codex.identity.cwd)), codexThreadId: codex.threadId };
|
|
274
274
|
}
|
|
275
275
|
if (marker && marker.identity.gardenId === selected.id) {
|
|
276
276
|
return buildMetaSenderEnvelope(marker.identity, marker.marker.cwd || cwd, marker);
|
|
@@ -603,21 +603,21 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operato
|
|
|
603
603
|
"sender envelope of that callback is its garden id — that is how you learn the address of something that " +
|
|
604
604
|
"did not exist a moment ago. This returns a LAUNCH receipt (tmux window/pane plus that nonce) and nothing " +
|
|
605
605
|
"else: it does NOT mean the runtime started, the first turn ran, or the task was delivered. Nothing polls " +
|
|
606
|
-
"for the callback; if it never arrives the window is visible
|
|
606
|
+
"for the callback; if it never arrives the window is visible. For EXISTING " +
|
|
607
607
|
"citizens use entwurf_v2 — this tool only creates, and entwurf_peers only reports. Model is REQUIRED and " +
|
|
608
608
|
"is passed to the chosen runtime CLI (`provider/model` for pi; model id/alias for Claude Code; a model name " +
|
|
609
609
|
"or `auto` for copilot; a fuzzy model pattern for omp or codex). Copilot, omp, and codex are refused BEFORE " +
|
|
610
|
-
"any window opens when their required birth, MCP, receive/delivery, or visible-identity units are absent
|
|
611
|
-
"Codex
|
|
612
|
-
"An optional " +
|
|
610
|
+
"any window opens when their required birth, MCP, receive/delivery, or visible-identity units are absent; " +
|
|
611
|
+
"Codex also requires the operator-owned default app-server socket, which entwurf never starts. An optional " +
|
|
613
612
|
"cwd starts the sibling in ONE literal absolute existing directory (cross-repo fresh) — never pick resume " +
|
|
614
|
-
"for a dormant record's cwd
|
|
615
|
-
"An optional placement.tmuxSession is an expert override naming ONE EXISTING session on this agent's own
|
|
616
|
-
"
|
|
617
|
-
"
|
|
613
|
+
"for a dormant record's cwd. Omitted/empty cwd means the caller's own directory. " +
|
|
614
|
+
"An optional placement.tmuxSession is an expert override naming ONE EXISTING session on this agent's own " +
|
|
615
|
+
"tmux server, and it ALWAYS wins. Omitted, the seat follows the CALLER: a CODEX CALLER opens beside its own " +
|
|
616
|
+
"TUI pane (matched by thread-id in that pane's title; 0 or 2+ matches REFUSE, never fall back), and every " +
|
|
617
|
+
"other caller opens in its own session. " +
|
|
618
|
+
"A missing named session is tmux-session-missing and NOTHING is created. " +
|
|
618
619
|
"There are no arbitrary command/env knobs. Do not put secrets in the task — model and task argv are visible to " +
|
|
619
|
-
"same-user processes on this host. Requires that this agent itself runs "
|
|
620
|
-
"inside tmux: without a pane anchor there is no session to open a sibling beside.", {
|
|
620
|
+
"same-user processes on this host. Requires that this agent itself runs inside tmux.", {
|
|
621
621
|
backend: z
|
|
622
622
|
.enum(["pi", "claude-code", "copilot", "omp", "codex"])
|
|
623
623
|
.describe("Which fixed runtime to open. Only these five; there is no arbitrary command."),
|
|
@@ -643,7 +643,7 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operato
|
|
|
643
643
|
cwd: z
|
|
644
644
|
.string()
|
|
645
645
|
.optional()
|
|
646
|
-
.describe("Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh).
|
|
646
|
+
.describe("Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh). Taken exactly as given — no trim, no realpath, no project-name resolution; '#' is refused (tmux format expansion). Omit or pass \"\" to start where the CALLER is: its own directory, or for a Codex caller its own record directory, because this bridge is the app-server's child and that process's directory is not the caller's. The receipt echoes the directory that was REQUESTED or the caller record it came from, never an observation of where the pane landed."),
|
|
647
647
|
placement: z
|
|
648
648
|
.object({
|
|
649
649
|
tmuxSession: z
|
|
@@ -651,12 +651,26 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operato
|
|
|
651
651
|
.describe("EXACT name of an EXISTING session on this agent's own tmux server. Nothing is created: an absent session is refused as tmux-session-missing, and a name outside [A-Za-z0-9][A-Za-z0-9_-]* as tmux-session-name-invalid."),
|
|
652
652
|
})
|
|
653
653
|
.optional()
|
|
654
|
-
.describe("Optional expert seat override: open the sibling in ONE EXISTING tmux session of this agent's own server. When omitted, Codex
|
|
654
|
+
.describe("Optional expert seat override: open the sibling in ONE EXISTING tmux session of this agent's own server, and it always wins. When omitted the seat follows the CALLER, never the backend being opened: a Codex CALLER opens beside its own TUI pane, matched by thread-id in that pane's terminal title (0 or 2+ matching panes refuse, never fall back); every other caller opens in its own session. A pane title is a placement input only — never an address, liveness or delivery fact. Nothing is ever created. Independent of cwd; neither is inferred from the other. The receipt reports the selected name (absent for the caller-pane rule, which observed a session rather than requesting a name), its source, and resolved target session id."),
|
|
655
655
|
}, async ({ backend, model, task, cwd, placement }, extra) => {
|
|
656
656
|
let callerGardenId = null;
|
|
657
|
+
// Present exactly when the reconciled caller is a record-backed codex citizen. Its ONLY
|
|
658
|
+
// consumer is the placement below: a Codex caller with no explicit seat opens its sibling
|
|
659
|
+
// beside its own TUI pane, found by that thread's terminal title (#95 lane B).
|
|
660
|
+
let callerNativeSessionId;
|
|
661
|
+
// The SAME citizen's record cwd, set under the SAME condition and carrying no further
|
|
662
|
+
// authority: `envelope.cwd` is only the record's directory when the selected sender is
|
|
663
|
+
// that codex citizen, so the two are derived from one fact rather than read separately.
|
|
664
|
+
// It exists because `process.cwd()` here is the operator-owned APP-SERVER's directory —
|
|
665
|
+
// this bridge is its MCP child — so a codex caller that names no cwd would otherwise open
|
|
666
|
+
// every sibling in the app-server's repo (#95 lane C §1). The composition consults it only
|
|
667
|
+
// when the tool call requested no cwd of its own.
|
|
668
|
+
let callerCwd;
|
|
657
669
|
try {
|
|
658
670
|
const self = await buildAuthoritativeSelfEnvelope({ requestMeta: extra._meta });
|
|
659
671
|
callerGardenId = self.envelope.sessionId;
|
|
672
|
+
callerNativeSessionId = self.codexThreadId;
|
|
673
|
+
callerCwd = self.codexThreadId === undefined ? undefined : self.envelope.cwd;
|
|
660
674
|
}
|
|
661
675
|
catch (err) {
|
|
662
676
|
// ONE error is a legitimate answer here: this host has no authoritative identity at all
|
|
@@ -671,10 +685,20 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operato
|
|
|
671
685
|
callerGardenId = null;
|
|
672
686
|
}
|
|
673
687
|
try {
|
|
674
|
-
|
|
688
|
+
// TWO capability axes, in this order, both pre-mutation and neither standing in for
|
|
689
|
+
// the other. The TARGET axis first — "entwurf cannot open a Codex sibling here at
|
|
690
|
+
// all" is the more fundamental answer than "and it would not know where to put it".
|
|
691
|
+
// The CALLER axis second, and only when the seat anchor will actually be consulted:
|
|
692
|
+
// a codex caller that named an explicit placement never reads a pane title, so
|
|
693
|
+
// refusing it for a missing `thread-id` would refuse an unused capability.
|
|
694
|
+
const targetMissing = backend === "codex" ? await codexFreshPreflight(process.env) : null;
|
|
695
|
+
const callerMissing = targetMissing === null && callerNativeSessionId !== undefined && placement === undefined
|
|
696
|
+
? codexCallerFreshPreflight(process.env)
|
|
697
|
+
: null;
|
|
698
|
+
const missing = targetMissing ?? callerMissing;
|
|
675
699
|
const result = missing
|
|
676
700
|
? { ok: false, reason: missing }
|
|
677
|
-
: freshCall({ backend, model, task, cwd, placement, callerGardenId });
|
|
701
|
+
: freshCall({ backend, model, task, cwd, placement, callerGardenId, callerNativeSessionId, callerCwd });
|
|
678
702
|
const rendered = renderFreshCall(result);
|
|
679
703
|
return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
|
|
680
704
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex-caller-seat — the ONE resolution of a Codex caller's `threadId` into the tmux pane its
|
|
3
|
+
* TUI is sitting in. Narrow leaf of the fresh-call composition (#95 lane B); it owns the title
|
|
4
|
+
* anchor and the pane count and NOTHING else — it never runs tmux (the runner is injected),
|
|
5
|
+
* never phrases a placement decision, never reads a record, and has no fallback seat.
|
|
6
|
+
*
|
|
7
|
+
* Same shape and same discipline as `resolve-tmux-session.ts` and `classify-tmux-cwd.ts`: this
|
|
8
|
+
* file imports nothing at all, not even a node builtin, so it stays deletable on its own and
|
|
9
|
+
* cannot acquire an opinion about mux, entwurf, identity or delivery. The injected runner is
|
|
10
|
+
* matched STRUCTURALLY to `mux-placement.TmuxRun` rather than by a type import, for the same
|
|
11
|
+
* reason.
|
|
12
|
+
*
|
|
13
|
+
* ── WHAT THIS IS NOT ──
|
|
14
|
+
*
|
|
15
|
+
* A pane title is OPERATOR-WRITABLE and forgeable: any process in any pane can emit the same
|
|
16
|
+
* OSC 0 string. So this leaf's answer is a PLACEMENT INPUT and nothing else — the `$session`
|
|
17
|
+
* it returns may only reach a `-t` target. Identity, delivery and liveness keep the record +
|
|
18
|
+
* `_meta` join they already have (AGENTS.md Hard Rule 16). Nothing here reads screen text,
|
|
19
|
+
* sends keys, or infers that a citizen is alive.
|
|
20
|
+
*
|
|
21
|
+
* ── THE ANCHOR, AND WHY IT IS NOT THE BARE UUID ──
|
|
22
|
+
*
|
|
23
|
+
* With `thread-id` in `[tui].terminal_title` (the `entwurf install-codex-terminal-title` atom)
|
|
24
|
+
* the vendor renders the thread UUID into the terminal title — but TRUNCATED. `[측정]
|
|
25
|
+
* 2026-09-16, thinkpad, codex-cli 0.153.4: a live TUI's `#{pane_title}` read back
|
|
26
|
+
* `tmp | 01a0a7f9-ed9c-7aa2-a4dd-b1a39...`, not the 36-char id. Source at `rust-v0.153.4`:
|
|
27
|
+
* `codex-rs/tui/src/chatwidget/status_surfaces.rs:892-894` renders `TerminalTitleItem::SessionId`
|
|
28
|
+
* through `truncate_terminal_title_part(value, 32)`, and `:1027-1043` keeps 29 graphemes and
|
|
29
|
+
* appends `...`. So the anchor set is TWO strings: the truncated form the vendor emits today,
|
|
30
|
+
* and the full id — accepted so that a vendor which later stops truncating passes unchanged
|
|
31
|
+
* rather than silently resolving nothing.
|
|
32
|
+
*
|
|
33
|
+
* `[측정]` 29 leading chars of a thread UUID are ASCII (`01a09ec6-e6de-7643-8571-f3261`, 29
|
|
34
|
+
* code points, 29 bytes — terra#1 2026-09-16 item 8), so counting code points here and
|
|
35
|
+
* graphemes there is the same count for every id this rail will ever see.
|
|
36
|
+
*
|
|
37
|
+
* `[측정]` `rust-v0.154.0` is unchanged on all three axes — the `thread-id` item
|
|
38
|
+
* (`title_setup.rs:81-83`), the 32-char truncation (`status_surfaces.rs:930-934`) and the
|
|
39
|
+
* ` | ` separator (`title_setup.rs:187-195`) — so this anchor is not pinned to one release.
|
|
40
|
+
*
|
|
41
|
+
* ── WHY TOKENS AND NOT ` | ` SEGMENTS ──
|
|
42
|
+
*
|
|
43
|
+
* The obvious rule is "one ` | `-separated segment equals the anchor". It has a vendor hole.
|
|
44
|
+
* `title_setup.rs:183-193 separator_from_previous` joins adjacent items with ` | ` EXCEPT when
|
|
45
|
+
* either side is the `activity` item (`Spinner`, `title_setup.rs:47-48`), which gets a plain
|
|
46
|
+
* space — and `status_surfaces.rs:330-343` computes that `previous` from the last RENDERED
|
|
47
|
+
* item, skipping any that resolved to `None`. So on a host whose operator list ends in
|
|
48
|
+
* `activity`, the installed atom appends `thread-id` right after it and a WORKING TUI renders
|
|
49
|
+
* `<spinner text> 01a0a7f9-…`: one segment, two values. Splitting each segment on a space and
|
|
50
|
+
* comparing TOKENS closes that hole and cannot open a new one — a false positive would need
|
|
51
|
+
* some other title item to render a string byte-identical to this thread's own id.
|
|
52
|
+
*
|
|
53
|
+
* ── WHY THE COUNT IS OVER PANES, NOT TOKENS ──
|
|
54
|
+
*
|
|
55
|
+
* One pane can legitimately show the same thread twice: an operator whose `terminal_title` also
|
|
56
|
+
* carries `thread-title` sees the full 36-char id there whenever the thread is unnamed
|
|
57
|
+
* (`status_surfaces.rs:776-786`), beside our truncated one. Two tokens, one pane, one thread —
|
|
58
|
+
* not an ambiguity. Ambiguity is TWO PANES claiming the same thread, and that is refused
|
|
59
|
+
* because picking either would seat a sibling by guess.
|
|
60
|
+
*/
|
|
61
|
+
/** The repair text for each refusal, owned by the leaf that decides it so the sentence an
|
|
62
|
+
* operator reads cannot drift away from the predicate that produced it (the same rule the
|
|
63
|
+
* Copilot/OMP/Codex preflight hints follow). */
|
|
64
|
+
export const CODEX_CALLER_SEAT_HINT = {
|
|
65
|
+
"codex-caller-seat-unresolved": "no pane on this agent's own tmux server shows this Codex thread in its title, so there is no caller seat to open a sibling beside — the TUI may be on another tmux server or outside tmux entirely, its config may not carry `thread-id` in [tui].terminal_title (run `entwurf install-codex-terminal-title`, then `entwurf doctor-codex-terminal-title`), or that server may have `allow-set-title off`, which replaces every pane title with the hostname and hides the id [측정 2026-09-16]",
|
|
66
|
+
"codex-caller-seat-ambiguous": "more than one pane on this agent's own tmux server shows this Codex thread in its title, so which one is the caller cannot be decided — nothing is opened rather than guessing a seat; close the stale duplicate and call again",
|
|
67
|
+
};
|
|
68
|
+
/** The vendor's per-item ceiling for `thread-id` (`status_surfaces.rs:892-894`). */
|
|
69
|
+
export const CODEX_TITLE_ITEM_MAX_CHARS = 32;
|
|
70
|
+
/** The two separators a rendered title can put between items: ` | ` for an ordinary pair, and a
|
|
71
|
+
* bare space when either neighbour is the `activity` indicator (`title_setup.rs:183-193`). */
|
|
72
|
+
const TITLE_SEGMENT_SEPARATOR = " | ";
|
|
73
|
+
const TITLE_TOKEN_SEPARATOR = " ";
|
|
74
|
+
/**
|
|
75
|
+
* The vendor's `truncate_terminal_title_part`, reproduced (`status_surfaces.rs:1027-1043`).
|
|
76
|
+
* Code points stand in for graphemes — measured identical for every thread id (see header).
|
|
77
|
+
*/
|
|
78
|
+
export function truncateTerminalTitlePart(value, maxChars) {
|
|
79
|
+
const chars = [...value];
|
|
80
|
+
if (chars.length <= maxChars || maxChars <= 3)
|
|
81
|
+
return chars.slice(0, maxChars).join("");
|
|
82
|
+
return `${chars.slice(0, maxChars - 3).join("")}...`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Every string a title may legitimately carry for this thread. The truncated form is what the
|
|
86
|
+
* vendor emits today; the full id is accepted so a future vendor that stops truncating keeps
|
|
87
|
+
* working without a code change here.
|
|
88
|
+
*/
|
|
89
|
+
export function codexTitleAnchors(threadId) {
|
|
90
|
+
if (threadId.length === 0) {
|
|
91
|
+
throw new Error("codex-caller-seat: refusing to build an anchor for an empty threadId");
|
|
92
|
+
}
|
|
93
|
+
const truncated = truncateTerminalTitlePart(threadId, CODEX_TITLE_ITEM_MAX_CHARS);
|
|
94
|
+
return truncated === threadId ? [threadId] : [threadId, truncated];
|
|
95
|
+
}
|
|
96
|
+
/** Does this ONE pane title name the thread? Segment first, then token — see the header for the
|
|
97
|
+
* `activity`-adjacency hole a segment-only rule leaves open. */
|
|
98
|
+
export function titleNamesThread(title, anchors) {
|
|
99
|
+
for (const segment of title.split(TITLE_SEGMENT_SEPARATOR)) {
|
|
100
|
+
for (const token of segment.split(TITLE_TOKEN_SEPARATOR)) {
|
|
101
|
+
if (anchors.includes(token))
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The lookup argv. `-a` is the whole server because a Codex TUI is not required to be in the
|
|
109
|
+
* caller's own session — that asymmetry is exactly what this leaf exists to remove. The three
|
|
110
|
+
* fields are tab-separated so a title containing spaces (a project name, the spinner text)
|
|
111
|
+
* cannot be read as a new column.
|
|
112
|
+
*/
|
|
113
|
+
export function buildCodexCallerSeatArgs() {
|
|
114
|
+
return ["list-panes", "-a", "-F", "#{pane_id}\t#{session_id}\t#{pane_title}"];
|
|
115
|
+
}
|
|
116
|
+
/** One `list-panes` line back into its three fields, or `null` when it is not one. The title
|
|
117
|
+
* takes EVERYTHING after the second tab: splitting on every tab would truncate a title that
|
|
118
|
+
* ever carried one. */
|
|
119
|
+
function parsePaneLine(line) {
|
|
120
|
+
const firstTab = line.indexOf("\t");
|
|
121
|
+
if (firstTab < 0)
|
|
122
|
+
return null;
|
|
123
|
+
const secondTab = line.indexOf("\t", firstTab + 1);
|
|
124
|
+
if (secondTab < 0)
|
|
125
|
+
return null;
|
|
126
|
+
const paneId = line.slice(0, firstTab);
|
|
127
|
+
const sessionId = line.slice(firstTab + 1, secondTab);
|
|
128
|
+
if (paneId.length === 0 || sessionId.length === 0)
|
|
129
|
+
return null;
|
|
130
|
+
return { paneId, sessionId, title: line.slice(secondTab + 1) };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the pane a Codex caller's thread is displayed in, on whatever server the runner's
|
|
134
|
+
* environment names.
|
|
135
|
+
*
|
|
136
|
+
* ONE BOUNDED IMPRECISION, STATED RATHER THAN LAUNDERED (the same one
|
|
137
|
+
* `resolve-tmux-session.ts` carries): rc≠0 also covers "no server running on this socket". This
|
|
138
|
+
* leaf reads every rc≠0 as `codex-caller-seat-unresolved`, so a server that died between the
|
|
139
|
+
* caller's context proof and this lookup is reported under the narrower word. That is safe —
|
|
140
|
+
* both readings are refusals that mutate nothing, and the hint above names both — and it is
|
|
141
|
+
* preferred over matching tmux's own stderr text, which would pin this leaf to one vendor
|
|
142
|
+
* version's wording.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveCodexCallerSeat(threadId, run) {
|
|
145
|
+
const anchors = codexTitleAnchors(threadId);
|
|
146
|
+
const result = run(buildCodexCallerSeatArgs());
|
|
147
|
+
// A signalled call is not tmux answering — it carries no information about any pane at all,
|
|
148
|
+
// so it must never be read as "the caller's TUI is not here".
|
|
149
|
+
if (result.status === null) {
|
|
150
|
+
throw new Error(`codex-caller-seat: the pane listing was killed by a signal: ${result.stderr.trim()}`);
|
|
151
|
+
}
|
|
152
|
+
if (result.status !== 0)
|
|
153
|
+
return { ok: false, reason: "codex-caller-seat-unresolved" };
|
|
154
|
+
const matches = [];
|
|
155
|
+
for (const line of result.stdout.split("\n")) {
|
|
156
|
+
if (line.length === 0)
|
|
157
|
+
continue;
|
|
158
|
+
const pane = parsePaneLine(line);
|
|
159
|
+
// A line this leaf cannot read is NOT a pane it may skip quietly: the listing is the
|
|
160
|
+
// whole evidence base for "exactly one", and a dropped line could be the second match
|
|
161
|
+
// that should have refused.
|
|
162
|
+
if (pane === null) {
|
|
163
|
+
throw new Error(`codex-caller-seat: tmux printed a pane line this leaf cannot read: ${JSON.stringify(line)}`);
|
|
164
|
+
}
|
|
165
|
+
if (titleNamesThread(pane.title, anchors)) {
|
|
166
|
+
matches.push({ paneId: pane.paneId, sessionId: pane.sessionId, source: "codex-title-anchor" });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (matches.length === 0)
|
|
170
|
+
return { ok: false, reason: "codex-caller-seat-unresolved" };
|
|
171
|
+
if (matches.length > 1)
|
|
172
|
+
return { ok: false, reason: "codex-caller-seat-ambiguous" };
|
|
173
|
+
return { ok: true, seat: matches[0] };
|
|
174
|
+
}
|
|
@@ -8,7 +8,14 @@ export const CODEX_PREFLIGHT_HINT = {
|
|
|
8
8
|
"codex-birth-trust-missing": "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`",
|
|
9
9
|
"codex-mcp-hand-missing": "the Codex user config does not expose the managed entwurf bridge; run `entwurf install-codex-mcp`, then `entwurf doctor-codex-mcp`",
|
|
10
10
|
"codex-visible-identity-missing": "Codex tui.status_line does not include thread-title; run `entwurf install-codex-statusline`, then `entwurf doctor-codex-statusline`",
|
|
11
|
-
"codex-app-server-unavailable": "the operator-owned Codex app-server default socket is absent or unsafe; start
|
|
11
|
+
"codex-app-server-unavailable": "the operator-owned Codex app-server default socket is absent or unsafe; start it with `entwurf codex-app-server` from a detached tmux session (one that is NOT where you work), then retry",
|
|
12
|
+
};
|
|
13
|
+
export const CODEX_LAUNCH_CWD_PREFLIGHT_HINT = {
|
|
14
|
+
"codex-launch-cwd-undecided": "Codex has recorded NO decision for the directory this sibling would start in, so the window would open on the vendor's folder-consent screen and WAIT for a human instead of running its first turn — no rollout, no callback, and nothing to address; either launch in a directory this Codex has already been answered for, or open a plain `codex -C <dir>` there once and answer it, then retry",
|
|
15
|
+
"codex-launch-cwd-untrusted-ancestor": "this directory sits inside a project the operator marked `untrusted`, and on this rail the vendor does not offer a consent screen for that — it refuses the startup outright with `pass the repository root explicitly with --cd`, so answering a prompt at this directory would only reproduce that error; launch at that untrusted project's own root instead, or change that project's recorded decision",
|
|
16
|
+
};
|
|
17
|
+
export const CODEX_CALLER_PREFLIGHT_HINT = {
|
|
18
|
+
"codex-caller-title-missing": "this Codex caller's tui.terminal_title does not include thread-id, so the multiplexer reports no pane title naming this thread and there is no caller seat to open the sibling beside; run `entwurf install-codex-terminal-title`, then `entwurf doctor-codex-terminal-title` (an explicit placement.tmuxSession skips this check entirely, because it never needs the seat)",
|
|
12
19
|
};
|
|
13
20
|
/**
|
|
14
21
|
* The unit's paths are the operator's own, derived from the SAME environment the installer
|
|
@@ -227,6 +234,192 @@ function visibleIdentityMissing(config) {
|
|
|
227
234
|
const statusLine = tui.status_line;
|
|
228
235
|
return !Array.isArray(statusLine) || !statusLine.includes("thread-title");
|
|
229
236
|
}
|
|
237
|
+
/** `[tui].terminal_title` membership, the exact axis `entwurf doctor-codex-terminal-title`
|
|
238
|
+
* judges. Same shape as `visibleIdentityMissing` and a DIFFERENT key: `status_line` is what a
|
|
239
|
+
* human reads inside the TUI, `terminal_title` is what the multiplexer reports back as
|
|
240
|
+
* `#{pane_title}`. Neither substitutes for the other. */
|
|
241
|
+
function callerTitleMissing(config) {
|
|
242
|
+
const tui = config.tui;
|
|
243
|
+
if (tui == null || typeof tui !== "object" || Array.isArray(tui))
|
|
244
|
+
return true;
|
|
245
|
+
const terminalTitle = tui.terminal_title;
|
|
246
|
+
return !Array.isArray(terminalTitle) || !terminalTitle.includes("thread-id");
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The CALLER-side capability, pre-mutation and synchronous.
|
|
250
|
+
*
|
|
251
|
+
* Synchronous because it reads one config file and nothing else: there is no app-server axis
|
|
252
|
+
* here and there must not be one — whether the operator's app-server is up says nothing about
|
|
253
|
+
* whether a caller's pane can be found, and asking would make a placement check fail for a
|
|
254
|
+
* delivery reason.
|
|
255
|
+
*
|
|
256
|
+
* Call it only when the anchor will actually be USED — a codex caller that named an explicit
|
|
257
|
+
* `placement` never consults the title, so refusing it for a missing `thread-id` would be a
|
|
258
|
+
* refusal for an unused capability.
|
|
259
|
+
*/
|
|
260
|
+
export function codexCallerFreshPreflight(env) {
|
|
261
|
+
const config = readConfig(env);
|
|
262
|
+
if (config === null || callerTitleMissing(config))
|
|
263
|
+
return "codex-caller-title-missing";
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The launch directory's own capability, pre-mutation and synchronous — and DELIBERATELY NARROWER
|
|
268
|
+
* than the vendor's own decision, because the leaf that decides here reads one TOML file while the
|
|
269
|
+
* vendor asks its app-server. Read this whole comment as the scope statement it is: what follows
|
|
270
|
+
* says what this answers, and then says what it refuses to claim.
|
|
271
|
+
*
|
|
272
|
+
* WHY THE AXIS EXISTS. `[source rust-v0.153.4]` a fresh call always passes `--remote`, so the TUI
|
|
273
|
+
* resolves `AppServerTarget::Remote` and startup ALWAYS runs `check_directory_trust` on the `-C`
|
|
274
|
+
* value (`tui/src/lib.rs:1699-1725`). Nothing on that path consults the approval or sandbox policy
|
|
275
|
+
* (`tui/src/onboarding/directory_trust.rs:33-130`), which is why the
|
|
276
|
+
* `--dangerously-bypass-approvals-and-sandbox` token this argv already carries does NOT cover it:
|
|
277
|
+
* approvals and folder consent are two different gates. A directory with no recorded decision
|
|
278
|
+
* renders the consent screen and blocks, and a blocked TUI has started no turn — no rollout, no
|
|
279
|
+
* birth, no callback, and the caller waits out its whole timeout on a window that is merely
|
|
280
|
+
* waiting to be answered. `[측정 2026-09-16]` the two release-gate runs that failed launched in
|
|
281
|
+
* `…-2zznHl` and `…-kSsoAn`, neither of which had an entry; the two that passed launched in
|
|
282
|
+
* `…-db65N2` and `…-pBXxOJ`, both of which did. Unattended, the failure is silent.
|
|
283
|
+
*
|
|
284
|
+
* THE THREE VENDOR OUTCOMES THIS LEAF MODELS, in the vendor's own order
|
|
285
|
+
* (`tui/src/config_update.rs:290-371`, `ProjectTrustHost::Remote`):
|
|
286
|
+
*
|
|
287
|
+
* 1. A DIRECT decision on the exact cwd starts a turn, and BOTH recorded answers do. `trusted`
|
|
288
|
+
* returns before any screen (`:346-354`); a saved `untrusted` is explicitly skipped for a
|
|
289
|
+
* remote target — `if target.uses_remote_workspace() && trust_level == Some(Untrusted) {
|
|
290
|
+
* continue; }` (`onboarding/directory_trust.rs:94-96`), and `uses_remote_workspace()` is
|
|
291
|
+
* exactly `matches!(self, Self::Remote { .. })` (`tui/src/lib.rs:307-309`). Refusing a
|
|
292
|
+
* deliberate `untrusted` would be entwurf inventing a policy the vendor does not have.
|
|
293
|
+
* The key is the exact cwd — `cwd_keys = vec![cwd_key]` (`:290-296`), no root marker, no git
|
|
294
|
+
* root, no parent, all three of which exist only on the Local host. `[측정 2026-09-16]`
|
|
295
|
+
* `~/.codex/config.toml` carried a trusted `/tmp` and still recorded two separate
|
|
296
|
+
* `/tmp/entwurf-codex-fresh-live-*` entries, one per run a human answered.
|
|
297
|
+
* 2. With NO direct decision, an enabled PROJECT LAYER consents on the directory's behalf
|
|
298
|
+
* (`trust_level.is_none() && disabled_project.is_none() && project_layers.any(no
|
|
299
|
+
* disabledReason)` → `Ok(None)`, `:346-354`). Those layers come from the app-server's
|
|
300
|
+
* `ConfigRead { include_layers: true }` answer, which this leaf does not have. A layer that
|
|
301
|
+
* is present but DISABLED is a fourth outcome — the vendor preserves an unknown or untrusted
|
|
302
|
+
* project layer as disabled rather than dropping it, and that path falls through to the
|
|
303
|
+
* screen — and this leaf cannot tell the two apart either. So wherever a layer could exist at
|
|
304
|
+
* all it answers `null`, which folds both outcomes into "proceed".
|
|
305
|
+
* 3. With no direct decision and NO layers, a cwd inside an explicitly `untrusted` ancestor is
|
|
306
|
+
* not a consent screen at all: the remote branch returns an ERROR
|
|
307
|
+
* (`"remote project directory is inside an explicitly untrusted project; pass the repository
|
|
308
|
+
* root explicitly with --cd"`, `:357-371`). That is a different failure with a different
|
|
309
|
+
* repair, so it gets its own reason — answering `Trust` at the child would only reproduce the
|
|
310
|
+
* same vendor error. Note the vendor's own precondition there is `project_layers.is_empty()`,
|
|
311
|
+
* which is why this reason is only reachable after step 2 has found no layer anywhere.
|
|
312
|
+
*
|
|
313
|
+
* WHAT THIS LEAF IS FOR, AND WHAT IT IS NOT. It is NOT equivalent to the vendor's judgment and
|
|
314
|
+
* must not be described as asking "the same question": it reads the operator's own `config.toml`,
|
|
315
|
+
* while the vendor reads an EFFECTIVE config — system, managed and cloud layers merged around that
|
|
316
|
+
* user layer (`config/src/loader/mod.rs:258-290`, `:430-460`) — through its app-server.
|
|
317
|
+
*
|
|
318
|
+
* That gap is affordable because NOTHING HERE REFUSES A LAUNCH. `freshCall` prints what this leaf
|
|
319
|
+
* saw and opens the window anyway: the consent screen is self-repairing when a human is there, and
|
|
320
|
+
* one answer teaches the vendor the directory for good. The one caller that treats the answer as a
|
|
321
|
+
* precondition is `smoke-codex-fresh-live`, where nobody is at the keyboard and a named
|
|
322
|
+
* precondition is worth more than a callback timeout — that gate owns one directory, answered
|
|
323
|
+
* once.
|
|
324
|
+
*
|
|
325
|
+
* So read a non-null answer as "the vendor will probably stop here, and this is the repair", never
|
|
326
|
+
* as a verdict. Absence is not evidence either way, and every case this leaf cannot see resolves
|
|
327
|
+
* to `null`.
|
|
328
|
+
*/
|
|
329
|
+
export function codexLaunchCwdFreshPreflight(env, launchCwd) {
|
|
330
|
+
// THREE NON-ANSWERS, AND EVERY ONE OF THEM PROCEEDS. Each is a case where this leaf holds no
|
|
331
|
+
// evidence about what the vendor will do, and a refusal without evidence is exactly the false
|
|
332
|
+
// refusal this axis promises not to produce:
|
|
333
|
+
//
|
|
334
|
+
// - A RELATIVE directory. It cannot match a stored key here, but the vendor does not give up
|
|
335
|
+
// on one — it asks its app-server for a cwd and joins (`config_update.rs:203-224`), so the
|
|
336
|
+
// joined path may well be answered. In production this branch is unreachable anyway: the
|
|
337
|
+
// shared cwd leaf already refuses a non-absolute request as `cwd-not-absolute`, which is a
|
|
338
|
+
// better reason than anything this axis could give.
|
|
339
|
+
// - NO READABLE USER CONFIG. A missing `config.toml` is not "no decisions": the vendor loads
|
|
340
|
+
// an empty user table and merges system, managed and cloud layers around it
|
|
341
|
+
// (`config/src/loader/mod.rs:258-290`, `:430-460`, `:520-610`), any of which can carry the
|
|
342
|
+
// decision or the layer that starts the turn.
|
|
343
|
+
// - NO `projects` TABLE. Same reason: it says the USER layer records nothing, not that the
|
|
344
|
+
// effective config does.
|
|
345
|
+
if (!path.isAbsolute(launchCwd))
|
|
346
|
+
return null;
|
|
347
|
+
const config = readConfig(env);
|
|
348
|
+
if (config === null)
|
|
349
|
+
return null;
|
|
350
|
+
const projects = config.projects;
|
|
351
|
+
if (projects == null || typeof projects !== "object" || Array.isArray(projects)) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
const table = projects;
|
|
355
|
+
const levelOf = (key) => {
|
|
356
|
+
const entry = table[key];
|
|
357
|
+
if (entry == null || typeof entry !== "object" || Array.isArray(entry))
|
|
358
|
+
return null;
|
|
359
|
+
const level = entry.trust_level;
|
|
360
|
+
// An unrecognised value leaves `trust_level` as `None` on the vendor side too, and `None`
|
|
361
|
+
// with no project layer is precisely the case that renders the screen.
|
|
362
|
+
return level === "trusted" || level === "untrusted" ? level : null;
|
|
363
|
+
};
|
|
364
|
+
// Outcome 1: a direct decision, either answer.
|
|
365
|
+
if (levelOf(launchCwd) !== null)
|
|
366
|
+
return null;
|
|
367
|
+
// Outcome 2: a PROJECT LAYER may consent with no entry at all, and this leaf cannot enumerate
|
|
368
|
+
// layers — they come from the app-server's `ConfigRead { include_layers: true }` answer. What
|
|
369
|
+
// it CAN decide is the negative: a `.codex` anywhere from the directory upward is the only
|
|
370
|
+
// place such a layer comes from, so when none exists the vendor's `project_layers` really is
|
|
371
|
+
// empty and the two remaining outcomes below are computable. Where one could exist, answer
|
|
372
|
+
// `null`.
|
|
373
|
+
//
|
|
374
|
+
// EXISTENCE, not ownership, and the weaker predicate is the load-bearing one. The vendor
|
|
375
|
+
// admits a layer on its own terms — an unknown or untrusted one is preserved as a DISABLED
|
|
376
|
+
// layer, not dropped — and none of that consults owner/mode/symlink safety. Asking for a
|
|
377
|
+
// safely-owned directory here would let a layer the vendor sees go unseen by this leaf, which
|
|
378
|
+
// would then synthesise an `untrusted-ancestor` refusal for a launch the vendor was going to
|
|
379
|
+
// run. That is the false refusal this axis must never produce, so the check is the widest
|
|
380
|
+
// thing that still means "a layer could live here".
|
|
381
|
+
//
|
|
382
|
+
// The operator's own CODEX HOME is excluded, and excluding it is what keeps this axis from
|
|
383
|
+
// being a no-op: `~/.codex` is an ancestor of nearly every directory anyone launches a sibling
|
|
384
|
+
// in, and it is the USER config root rather than a project layer — the vendor names those
|
|
385
|
+
// apart (`layer.name.dotCodexFolder` for a project, the home for the user layer). Counting it
|
|
386
|
+
// would answer `null` for every path under `$HOME` and the check would never fire in real use.
|
|
387
|
+
const codexHome = path.resolve(env.CODEX_HOME?.trim() || path.join(env.HOME ?? "", ".codex"));
|
|
388
|
+
for (let dir = launchCwd;;) {
|
|
389
|
+
const candidate = path.join(dir, ".codex");
|
|
390
|
+
if (candidate !== codexHome && fs.existsSync(candidate))
|
|
391
|
+
return null;
|
|
392
|
+
const parent = path.dirname(dir);
|
|
393
|
+
if (parent === dir)
|
|
394
|
+
break;
|
|
395
|
+
dir = parent;
|
|
396
|
+
}
|
|
397
|
+
// Outcome 3: no layer anywhere and an explicitly untrusted ancestor — the vendor's own
|
|
398
|
+
// precondition for that branch is exactly `project_layers.is_empty()`, which the loop above
|
|
399
|
+
// has now established. It is reported as its own failure because its repair is to launch at
|
|
400
|
+
// that root, never to answer a prompt at the child.
|
|
401
|
+
//
|
|
402
|
+
// The MATCH IS NOT THE VENDOR'S and is not claimed to be. The vendor compares path URIs
|
|
403
|
+
// (`LegacyAppPathString` → `PathUri::starts_with`, segment-aware, fail-closed on encoded
|
|
404
|
+
// separators); this compares plain strings on a separator boundary. Where the two could
|
|
405
|
+
// disagree — a key or a cwd that is not a plain POSIX path — this answers `undecided` rather
|
|
406
|
+
// than `untrusted-ancestor`. Be exact about what that buys: BOTH are refusals, so this is NOT
|
|
407
|
+
// the launch-permissive direction. What it weakens is the SPECIFIC-ERROR claim — the ancestor
|
|
408
|
+
// reason names another directory as the repair, and naming the wrong one is worse than saying
|
|
409
|
+
// "no decision here". Neither vendor outcome on this branch starts a turn, so the guarantee
|
|
410
|
+
// above is untouched either way.
|
|
411
|
+
const plainPosixPath = (value) => !value.includes("%") && !value.includes("\\") && !value.split("/").some((seg) => seg === "." || seg === "..");
|
|
412
|
+
if (plainPosixPath(launchCwd)) {
|
|
413
|
+
for (const key of Object.keys(table)) {
|
|
414
|
+
if (levelOf(key) !== "untrusted" || !plainPosixPath(key))
|
|
415
|
+
continue;
|
|
416
|
+
if (launchCwd === key || launchCwd.startsWith(key.endsWith("/") ? key : `${key}/`)) {
|
|
417
|
+
return "codex-launch-cwd-untrusted-ancestor";
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return "codex-launch-cwd-undecided";
|
|
422
|
+
}
|
|
230
423
|
const DEFAULT_APP_SERVER_TIMEOUT_MS = 5_000;
|
|
231
424
|
function withTimeout(promise, timeoutMs) {
|
|
232
425
|
return new Promise((resolve, reject) => {
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Why a result type richer than the receipt: the carry-over contracts from 5c demand it.
|
|
14
14
|
* - N3 (5c-2b): a dead-path re-resolve `rejected` carries the resolver's `rejectReason`
|
|
15
|
-
* (dormant-fire-forget-unsupported / mailbox-undeliverable / …)
|
|
16
|
-
*
|
|
15
|
+
* (dormant-fire-forget-unsupported / mailbox-undeliverable / …), while an in-band
|
|
16
|
+
* refusal carries its supplied receiver error. The runner carries either verbatim;
|
|
17
|
+
* receiver evidence is not reclassified as resolver taxonomy.
|
|
17
18
|
* - N1 (5c-2a): a non-`failed` outcome whose `releaseLock` then threw is a
|
|
18
19
|
* `SendDeliveredReleaseFailedError` — the delivery HAPPENED, the lock is dirty, a
|
|
19
20
|
* re-send would double-deliver. The runner surfaces this as `execution-failed` with
|
|
@@ -76,8 +76,9 @@ async function driveSend(plan, lock, deps) {
|
|
|
76
76
|
// dead ⇒ proven non-delivery ⇒ same-lock one-shot re-resolve (lock still held).
|
|
77
77
|
return await driveDeadFallback(plan, lock, deps);
|
|
78
78
|
}
|
|
79
|
-
// A completed RPC: ack ⇒ sent; in-band refusal ⇒ rejected, NO fallback.
|
|
80
|
-
|
|
79
|
+
// A completed RPC: ack ⇒ sent; in-band refusal ⇒ rejected, NO fallback. Preserve
|
|
80
|
+
// its receiver error verbatim when supplied; do not make a reason up when absent.
|
|
81
|
+
return result.success ? { outcome: "sent" } : inBandRejected(result);
|
|
81
82
|
}
|
|
82
83
|
/**
|
|
83
84
|
* The dead-path fallback: re-resolve ONCE under the held lock, then run the resolver's
|
|
@@ -85,6 +86,9 @@ async function driveSend(plan, lock, deps) {
|
|
|
85
86
|
* re-enter the fallback, it finalizes as failed). The hand only executes; the resolver
|
|
86
87
|
* decided.
|
|
87
88
|
*/
|
|
89
|
+
function inBandRejected(result) {
|
|
90
|
+
return { outcome: "rejected", rejectReason: result.error === "" ? undefined : result.error };
|
|
91
|
+
}
|
|
88
92
|
async function driveDeadFallback(plan, lock, deps) {
|
|
89
93
|
let resolution;
|
|
90
94
|
try {
|
|
@@ -112,7 +116,7 @@ async function driveDeadFallback(plan, lock, deps) {
|
|
|
112
116
|
// connect failure finalizes as failed (no further fallback).
|
|
113
117
|
try {
|
|
114
118
|
const r = await deps.sendOverSocket(rePlan);
|
|
115
|
-
return
|
|
119
|
+
return r.success ? { outcome: "fallback-sent" } : inBandRejected(r);
|
|
116
120
|
}
|
|
117
121
|
catch (err) {
|
|
118
122
|
return { outcome: "failed", error: err };
|
|
@@ -125,7 +129,7 @@ async function driveDeadFallback(plan, lock, deps) {
|
|
|
125
129
|
// #98 R: this leg writes a `.msg` exactly like the primary mailbox rail, so it
|
|
126
130
|
// owes the sender the same per-message receipt. Carried only on success — a
|
|
127
131
|
// `rejected` enqueue wrote no file to name.
|
|
128
|
-
return r.success ? { outcome: "fallback-sent", messagePath: r.messagePath } :
|
|
132
|
+
return r.success ? { outcome: "fallback-sent", messagePath: r.messagePath } : inBandRejected(r);
|
|
129
133
|
}
|
|
130
134
|
catch (err) {
|
|
131
135
|
return { outcome: "failed", error: err };
|