viber-channel 0.8.32 → 0.8.33

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.
@@ -75,6 +75,9 @@ export function createBridgeSpawn(opts: BridgeSpawnOptions = {}): SpawnFn {
75
75
  // authoritatively below (codex-309-review blocking #1).
76
76
  delete env.VIBER_AGENT_NAME_EXPLICIT;
77
77
  delete env.VIBER_AGENT_ROLE;
78
+ // #665: a parent's codex model/effort must never pin a fresh agent; spec.env may set them.
79
+ delete env.VIBER_CODEX_MODEL;
80
+ delete env.VIBER_CODEX_EFFORT;
78
81
  env.VIBER_AGENT_TIER = spec.tier;
79
82
  if (spec.env) Object.assign(env, spec.env);
80
83
  // Identity is set LAST, AFTER spec.env, so neither the inherited env nor a
@@ -96,8 +96,9 @@ export function capabilitiesText(channelVersion: string | null = null): string {
96
96
  " → agent \"g1-dev\", team \"g1-solo\"",
97
97
  " runtime: claude|codex|gemma · permission: read-only|read-write (gemma: read-only",
98
98
  " only; all roles of one template must share one permission) · count optional;",
99
- " model/effort: leave them OUT (runtime default) UNLESS the owner explicitly asks",
100
- " for one; claude only: model opus|sonnet|haiku|fable, effort low|medium|high|xhigh|max.",
99
+ " model/effort: on claude leave them OUT (runtime default) UNLESS the owner asks:",
100
+ " model opus|sonnet|haiku|fable, effort low|medium|high|xhigh|max. On codex BOTH are",
101
+ " REQUIRED: gpt-5.6-sol / low unless the owner asks for another listed pair.",
101
102
  " (On the web, sidebar ⋯ → Add agent launches ONE named agent without a template edit.)",
102
103
  "",
103
104
  "DISSOLVE (stops the processes, then revokes the proven-stopped instances):",
@@ -145,11 +146,16 @@ export function capabilitiesText(channelVersion: string | null = null): string {
145
146
  " runtime REQUIRED claude | codex | gemma",
146
147
  " permission REQUIRED read-only | read-write (gemma: read-only only)",
147
148
  " rolePrompt REQUIRED non-empty, ≤ 600 chars AFTER {prefix} expands to 32 chars",
148
- " model optional claude: opus | sonnet | haiku | fable (a family, never a",
149
- " version); other runtimes: ≤ 64, stored but not applied",
150
- " effort optional claude only: low | medium | high | xhigh | max",
151
- " model/effort: leave them OUT (runtime default) unless the",
152
- " owner explicitly asks for one",
149
+ " model claude: optional, opus | sonnet | haiku | fable (a family, never a",
150
+ " version); codex: REQUIRED, one of gpt-6.1-sol gpt-6-astra",
151
+ " gpt-6-sol gpt-6-luna gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna",
152
+ " gpt-5.5; gemma: ≤ 64, stored but not applied",
153
+ " effort claude: optional, low | medium | high | xhigh | max;",
154
+ " codex: REQUIRED, one the model accepts — low..xhigh on all,",
155
+ " max except gpt-5.5, ultra except the luna models and gpt-5.5;",
156
+ " gemma: refused",
157
+ " claude: leave model/effort OUT (runtime default) unless the",
158
+ " owner asks; codex: gpt-5.6-sol / low unless the owner asks",
153
159
  " count optional integer 1..10",
154
160
  " name: non-empty, ≤ 80, unique per owner, case-insensitive (409 without code = taken).",
155
161
  " update: send ONLY the fields you change (name / spec / project_id) plus",
@@ -0,0 +1,166 @@
1
+ /**
2
+ * #665 — the model and reasoning effort a codex agent runs with.
3
+ *
4
+ * vibe-master hands them to the bridge as `VIBER_CODEX_MODEL` / `VIBER_CODEX_EFFORT`: BOTH or
5
+ * NEITHER. Neither is a role stored before #665 — the bridge then behaves exactly as before (no
6
+ * catalogue call, no field on `thread/start` / `turn/start`). A pair is:
7
+ * - checked ONCE at startup against the catalogue of THIS codex (`model/list`), before any
8
+ * thread or turn exists — the target is the pair SET by the template, never the model a
9
+ * resumed thread reports (that one is the OLD model);
10
+ * - applied on `thread/start` (a fresh thread) and on EVERY `turn/start`, which also moves a
11
+ * resumed thread onto the template's model.
12
+ * Anything it cannot honour stops the bridge with a message naming the pair: no silent fallback.
13
+ */
14
+
15
+ export interface CodexPair {
16
+ model: string;
17
+ effort: string;
18
+ }
19
+
20
+ /** A page of `model/list`, as codex-cli 0.159.3 answers it (only the fields read here). */
21
+ export interface ModelListPage {
22
+ data?: { id?: unknown; model?: unknown; supportedReasoningEfforts?: unknown }[];
23
+ nextCursor?: unknown;
24
+ }
25
+
26
+ export type ModelListFetch = (cursor: string | null) => Promise<ModelListPage>;
27
+
28
+ /** Read the pair from the env: both, or neither (`""` is unset, the #411 convention). */
29
+ export function readCodexPair(env: NodeJS.ProcessEnv): CodexPair | undefined {
30
+ const model = env.VIBER_CODEX_MODEL?.trim() ?? "";
31
+ const effort = env.VIBER_CODEX_EFFORT?.trim() ?? "";
32
+ if (model === "" && effort === "") return undefined;
33
+ if (model === "" || effort === "") {
34
+ throw new Error(
35
+ `codex model/effort must come together: got VIBER_CODEX_MODEL="${model}", VIBER_CODEX_EFFORT="${effort}" — ` +
36
+ "relaunch from a template that sets both",
37
+ );
38
+ }
39
+ return { model, effort };
40
+ }
41
+
42
+ /** The `thread/start` fields for a pair (codex has no direct effort field there: it is config). */
43
+ export function threadStartPairParams(pair: CodexPair | undefined): Record<string, unknown> {
44
+ if (pair === undefined) return {};
45
+ return { model: pair.model, config: { model_reasoning_effort: pair.effort } };
46
+ }
47
+
48
+ /** The `turn/start` fields for a pair — they persist for later turns, resumed threads included. */
49
+ export function turnStartPairParams(pair: CodexPair | undefined): Record<string, unknown> {
50
+ if (pair === undefined) return {};
51
+ return { model: pair.model, effort: pair.effort };
52
+ }
53
+
54
+ /** The efforts a catalogue entry accepts (`[{ reasoningEffort }]`, or bare strings). */
55
+ function effortsOf(entry: { supportedReasoningEfforts?: unknown }): string[] {
56
+ const raw = entry.supportedReasoningEfforts;
57
+ if (!Array.isArray(raw)) throw new Error("model/list entry without supportedReasoningEfforts");
58
+ return raw.map((e) => {
59
+ const v = typeof e === "string" ? e : (e as { reasoningEffort?: unknown } | null)?.reasoningEffort;
60
+ if (typeof v !== "string") throw new Error("model/list entry with a malformed reasoning effort");
61
+ return v;
62
+ });
63
+ }
64
+
65
+ /** Walk every page of `model/list`; a repeated cursor is a loop, not a catalogue. */
66
+ async function collectCatalogue(fetchPage: ModelListFetch, expired: () => boolean): Promise<Map<string, string[]>> {
67
+ const catalogue = new Map<string, string[]>();
68
+ const seen = new Set<string>();
69
+ let cursor: string | null = null;
70
+ do {
71
+ // Past the deadline the caller has already refused: stop asking, do not walk on in the dark.
72
+ if (expired()) throw new Error("model/list walk stopped at the deadline");
73
+ const page = await fetchPage(cursor);
74
+ if (!Array.isArray(page.data)) throw new Error("model/list answered without a data array");
75
+ for (const entry of page.data) {
76
+ // `model` is the slug `thread/start` / `turn/start` take, guaranteed by 0.159.3. `id` equals
77
+ // it today but is not read: an entry without `model` is malformed, not resolved by guess.
78
+ const id = entry.model;
79
+ if (typeof id !== "string") throw new Error("model/list entry without a model");
80
+ catalogue.set(id, effortsOf(entry));
81
+ }
82
+ const next = page.nextCursor;
83
+ if (next !== null && next !== undefined && typeof next !== "string") {
84
+ throw new Error("model/list answered a non-string nextCursor");
85
+ }
86
+ cursor = next ?? null;
87
+ if (cursor !== null) {
88
+ if (seen.has(cursor)) throw new Error(`model/list repeated the cursor "${cursor}"`);
89
+ seen.add(cursor);
90
+ }
91
+ } while (cursor !== null);
92
+ return catalogue;
93
+ }
94
+
95
+ /**
96
+ * Throw unless THIS codex's catalogue carries the pair. One deadline covers the whole pagination;
97
+ * an RPC failure, a malformed answer or the deadline all throw — the caller stops the bridge.
98
+ */
99
+ export async function verifyCodexPair(
100
+ pair: CodexPair,
101
+ fetchPage: ModelListFetch,
102
+ timeoutMs: number,
103
+ ): Promise<void> {
104
+ let timer: ReturnType<typeof setTimeout> | undefined;
105
+ let expired = false;
106
+ const deadline = new Promise<never>((_, reject) => {
107
+ timer = setTimeout(() => {
108
+ expired = true;
109
+ reject(new Error(`model/list did not answer within ${timeoutMs} ms`));
110
+ }, timeoutMs);
111
+ });
112
+ const walk = collectCatalogue(fetchPage, () => expired);
113
+ walk.catch(() => {}); // after the deadline its own rejection has nobody left to tell
114
+ let catalogue: Map<string, string[]>;
115
+ try {
116
+ catalogue = await Promise.race([walk, deadline]);
117
+ } catch (err) {
118
+ const why = err instanceof Error ? err.message : String(err);
119
+ throw new Error(`cannot check codex model ${pair.model} / effort ${pair.effort}: ${why}`);
120
+ } finally {
121
+ clearTimeout(timer);
122
+ }
123
+ const efforts = catalogue.get(pair.model);
124
+ if (efforts === undefined) {
125
+ throw new Error(
126
+ `codex model ${pair.model} is absent from the catalogue of THIS codex (has: ${[...catalogue.keys()].join(", ")}) — ` +
127
+ "update codex or re-edit the template",
128
+ );
129
+ }
130
+ if (!efforts.includes(pair.effort)) {
131
+ throw new Error(
132
+ `codex model ${pair.model} does not accept effort ${pair.effort} on THIS codex (accepts: ${efforts.join(", ")}) — ` +
133
+ "re-edit the template",
134
+ );
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Why codex is NOT running the pair, judged from what `thread/start` / `thread/resume` answer
140
+ * (`model`, `reasoningEffort` — the thread's EFFECTIVE settings), or undefined when it is. A null
141
+ * effort is "not reported", not a mismatch. No pair: nothing to compare (pre-#665 behaviour).
142
+ */
143
+ export function pairMismatch(
144
+ pair: CodexPair | undefined,
145
+ response: unknown,
146
+ method: string,
147
+ ): string | undefined {
148
+ if (pair === undefined) return undefined;
149
+ const r = (typeof response === "object" && response !== null ? response : {}) as {
150
+ model?: unknown;
151
+ reasoningEffort?: unknown;
152
+ };
153
+ if (r.model !== pair.model) {
154
+ return `codex ${method} runs model ${String(r.model)}, not the template's ${pair.model} — stopping rather than run the wrong model`;
155
+ }
156
+ // null = "not reported" (the field is nullable in 0.159.3); absent or any other type is a
157
+ // malformed answer, refused rather than read as a pass (Codex-2 review of cecd6ff7).
158
+ if (r.reasoningEffort === null) return undefined;
159
+ if (typeof r.reasoningEffort !== "string") {
160
+ return `codex ${method} answered a malformed reasoningEffort (${JSON.stringify(r.reasoningEffort) ?? "absent"}) — stopping rather than guess the effort`;
161
+ }
162
+ if (r.reasoningEffort !== pair.effort) {
163
+ return `codex ${method} runs effort ${r.reasoningEffort}, not the template's ${pair.effort} — stopping rather than run the wrong effort`;
164
+ }
165
+ return undefined;
166
+ }
@@ -74,7 +74,7 @@ export interface RegistryRole {
74
74
  permission: string;
75
75
  rolePrompt?: string;
76
76
  model?: string;
77
- /** #656: claude reasoning effort, replayed as `--effort` on a relaunch. */
77
+ /** #656 claude / #665 codex reasoning effort, replayed as `--effort` on a relaunch. */
78
78
  effort?: string;
79
79
  }
80
80
 
@@ -56,8 +56,8 @@ export const TEAM_TEMPLATES_TOOL_DEF = {
56
56
  description:
57
57
  "The roster, for create/update: { roles: [{ role, runtime, permission, rolePrompt, model?, effort?, count? }] } — same shape `get` returns. " +
58
58
  "On update a spec REPLACES the whole spec: send every role. " +
59
- "model: leave it OUT (the runtime picks its own default) UNLESS the owner explicitly asks for a model; on claude it is a family: opus, sonnet, haiku or fable. " +
60
- "effort (claude only): low, medium, high, xhigh or max — same rule, leave it OUT unless asked. " +
59
+ "claude: leave model and effort OUT (the runtime picks its own default) UNLESS the owner explicitly asks; model is a family (opus, sonnet, haiku or fable), effort low, medium, high, xhigh or max. " +
60
+ "codex: model AND effort are REQUIRED — use gpt-5.6-sol / low unless the owner asks for another listed model and an effort it accepts. " +
61
61
  "Full field rules: the capabilities tool.",
62
62
  },
63
63
  project_id: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.32",
3
+ "version": "0.8.33",
4
4
  "description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -123,6 +123,15 @@ import { startBridgeToolHost, type BridgeToolHost } from "./lib/bridge_tool_host
123
123
  import type { AgentToolsContext } from "./lib/agent_tools.ts";
124
124
  import type { OpenDmResult } from "./lib/peers.ts";
125
125
  import { resolveLockDir } from "./lib/lock_dir.ts";
126
+ import {
127
+ type CodexPair,
128
+ type ModelListPage,
129
+ readCodexPair,
130
+ threadStartPairParams,
131
+ turnStartPairParams,
132
+ pairMismatch,
133
+ verifyCodexPair,
134
+ } from "./lib/codex_model_pair.ts";
126
135
 
127
136
  // #480: transport host vs logical identity. IDENTITY_URL is what the session and
128
137
  // bridge-lock hashes are keyed on (this file builds several of them, below) —
@@ -673,7 +682,8 @@ class OutboundTurnTracker {
673
682
  }
674
683
  }
675
684
 
676
- class CodexAppServer {
685
+ /** Exported for its #665 witnesses only (a fake child stands in for `codex app-server`). */
686
+ export class CodexAppServer {
677
687
  private child: ChildProcessWithoutNullStreams;
678
688
  private nextId = 1;
679
689
  private stdoutBuffer = "";
@@ -700,6 +710,10 @@ class CodexAppServer {
700
710
  * env (read through `bearer_token_env_var`) so it never lands in argv/config.
701
711
  */
702
712
  mcp?: { url: string; bearerEnvVar: string; bearerValue: string },
713
+ /** #665: the model/effort the template set, or undefined (a role stored before #665). */
714
+ private readonly pair?: CodexPair,
715
+ /** Test seam (#665): what launches `codex app-server`. Production passes nothing. */
716
+ spawnChild: typeof spawn = spawn,
703
717
  ) {
704
718
  const codexBin = process.env.VIBER_CODEX_BIN?.trim() || "codex";
705
719
  const args = ["app-server"];
@@ -735,7 +749,7 @@ class CodexAppServer {
735
749
  args.push("-c", `mcp_servers.viber.bearer_token_env_var="${mcp.bearerEnvVar}"`);
736
750
  childEnv[mcp.bearerEnvVar] = mcp.bearerValue;
737
751
  }
738
- this.child = spawn(codexBin, args, {
752
+ this.child = spawnChild(codexBin, args, {
739
753
  cwd: process.cwd(),
740
754
  env: childEnv,
741
755
  stdio: ["pipe", "pipe", "pipe"],
@@ -800,6 +814,7 @@ class CodexAppServer {
800
814
  const existing = state.threads[key]?.thread_id;
801
815
  const t0 = nowMs(); // #409: measure thread resume/start (the first-DM cost)
802
816
  let resumeAttempted = false;
817
+ let resumeMismatch: string | undefined;
803
818
 
804
819
  if (existing && !options.newThread) {
805
820
  try {
@@ -813,17 +828,26 @@ class CodexAppServer {
813
828
  sandbox: AGENT_SANDBOX,
814
829
  developerInstructions: AGENT_INSTRUCTIONS,
815
830
  excludeTurns: true,
831
+ // #665: the pair at resume time too — a resumed thread was created on the model of
832
+ // its FIRST launch; this moves it onto the template's, like turn/start does.
833
+ ...threadStartPairParams(this.pair),
816
834
  });
817
835
  // #409 (Codex): validate/extract the id BEFORE logging success — a malformed
818
836
  // response must NOT be logged as resume then again as resume-failed→start.
819
837
  const resumedId = extractThreadId(resumed);
820
- logTiming("ensureThread", nowMs() - t0, "outcome=resume");
821
- return resumedId;
838
+ // #665: codex answers the thread's EFFECTIVE model — checked, never assumed. A mismatch
839
+ // leaves this try (it must not fall back to a fresh thread and hide the problem).
840
+ resumeMismatch = pairMismatch(this.pair, resumed, "thread/resume");
841
+ if (resumeMismatch === undefined) {
842
+ logTiming("ensureThread", nowMs() - t0, "outcome=resume");
843
+ return resumedId;
844
+ }
822
845
  } catch (err) {
823
846
  process.stderr.write(
824
847
  `[viber-codex-bridge] codex: resume failed (${safeErrorMessage(err)}), starting a fresh thread\n`,
825
848
  );
826
849
  }
850
+ if (resumeMismatch !== undefined) this.stopOnPairMismatch(resumeMismatch);
827
851
  }
828
852
 
829
853
  process.stderr.write("[viber-codex-bridge] codex: starting fresh bridge-owned thread\n");
@@ -836,8 +860,18 @@ class CodexAppServer {
836
860
  ephemeral: false,
837
861
  threadSource: "user",
838
862
  sessionStartSource: options.newThread ? "clear" : "startup",
863
+ ...threadStartPairParams(this.pair),
864
+ }).catch((err: unknown) => {
865
+ // #665: a refusal caused by the pair must name it, not surface as a bare RPC error.
866
+ if (this.pair === undefined) throw err;
867
+ throw new Error(
868
+ `codex refused thread/start with model ${this.pair.model} / effort ${this.pair.effort}: ${safeErrorMessage(err)}`,
869
+ );
839
870
  });
840
871
  const threadId = extractThreadId(started);
872
+ // #665: same check on a fresh thread — what codex SAYS it runs, not what we asked.
873
+ const startMismatch = pairMismatch(this.pair, started, "thread/start");
874
+ if (startMismatch !== undefined) this.stopOnPairMismatch(startMismatch);
841
875
  state.threads[key] = {
842
876
  thread_id: threadId,
843
877
  conversation_id: options.conversationId,
@@ -867,11 +901,36 @@ class CodexAppServer {
867
901
  sandboxPolicy: buildTurnSandboxPolicy(AGENT_TIER),
868
902
  developerInstructions: AGENT_INSTRUCTIONS,
869
903
  responsesapiClientMetadata: { source: "viber-codex-bridge" },
904
+ // #665: on EVERY turn — it also moves a resumed thread onto the template's model.
905
+ ...turnStartPairParams(this.pair),
870
906
  });
871
907
  const turnId = this.extractTurnId(response);
872
908
  return await this.waitForTurn(threadId, turnId);
873
909
  }
874
910
 
911
+ /**
912
+ * #665: check the pair against THIS codex's catalogue (`model/list`) — no thread, no turn, so
913
+ * it runs before any `turn/start`. No pair: nothing is called (the pre-#665 behaviour).
914
+ */
915
+ /** #665: codex is not running the template's pair — stop the bridge in clear, never run on. */
916
+ private stopOnPairMismatch(reason: string): never {
917
+ process.stderr.write(`${LOG_PREFIX} #665: ${reason}\n`);
918
+ this.onUnexpectedExit?.(reason);
919
+ throw new Error(reason);
920
+ }
921
+
922
+ async verifyPair(timeoutMs: number): Promise<void> {
923
+ if (this.pair === undefined) return;
924
+ await verifyCodexPair(
925
+ this.pair,
926
+ async (cursor) => (await this.request("model/list", { cursor, includeHidden: false })) as ModelListPage,
927
+ timeoutMs,
928
+ );
929
+ process.stderr.write(
930
+ `${LOG_PREFIX} #665: codex model ${this.pair.model} / effort ${this.pair.effort} — found in this codex's catalogue\n`,
931
+ );
932
+ }
933
+
875
934
  close(): void {
876
935
  this.closing = true;
877
936
  this.child.kill();
@@ -1382,6 +1441,8 @@ function setupCodexRuntime(deps: {
1382
1441
  requestShutdown: (err: BridgeShutdownError) => void;
1383
1442
  /** #627: why the bridge is paused (null = may write). */
1384
1443
  pauseReason: () => string | null;
1444
+ /** #665: the template's model/effort, or undefined. */
1445
+ pair: CodexPair | undefined;
1385
1446
  }): { codex: CodexAppServer; toolHost: BridgeToolHost } {
1386
1447
  const { tracker } = deps;
1387
1448
  const bridgeToolsCtx: AgentToolsContext = {
@@ -1417,10 +1478,39 @@ function setupCodexRuntime(deps: {
1417
1478
  deps.requestShutdown(new BridgeShutdownError(reason, 3));
1418
1479
  },
1419
1480
  { url: toolHost.url, bearerEnvVar: "VIBER_MCP_TOKEN", bearerValue: toolHost.bearerToken },
1481
+ deps.pair,
1420
1482
  );
1421
1483
  return { codex, toolHost };
1422
1484
  }
1423
1485
 
1486
+ /** #665: one deadline for the whole `model/list` walk at startup (it may refresh over network). */
1487
+ const CODEX_PAIR_CHECK_TIMEOUT_MS = 30_000;
1488
+
1489
+ /**
1490
+ * #665: initialize codex and check its pair. On ANY failure — initialize, catalogue refusal,
1491
+ * deadline — `release` runs (child, tool host, lock) BEFORE the error leaves as a
1492
+ * BridgeShutdownError (a refused pair) or as itself (initialize): on the `--conversation` path nothing
1493
+ * else would release them.
1494
+ */
1495
+ export async function startCheckedCodex(
1496
+ codex: Pick<CodexAppServer, "initialize" | "verifyPair">,
1497
+ timeoutMs: number,
1498
+ release: () => void,
1499
+ ): Promise<void> {
1500
+ try {
1501
+ await codex.initialize();
1502
+ } catch (err) {
1503
+ release();
1504
+ throw err; // unchanged type: an initialize failure stays a crash, as before #665 (Opus review)
1505
+ }
1506
+ try {
1507
+ await codex.verifyPair(timeoutMs);
1508
+ } catch (err) {
1509
+ release();
1510
+ throw new BridgeShutdownError(safeErrorMessage(err), 2); // a refused pair is a deliberate end
1511
+ }
1512
+ }
1513
+
1424
1514
  function logCodexSafety(): void {
1425
1515
  process.stderr.write(
1426
1516
  `${LOG_PREFIX} codex safety: tier=${AGENT_TIER}, sandbox=${AGENT_SANDBOX}, approvalPolicy=${AGENT_APPROVAL_POLICY}, network=${networkForTier(AGENT_TIER)}, ` +
@@ -1436,6 +1526,8 @@ function logCodexSafety(): void {
1436
1526
 
1437
1527
  export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
1438
1528
  const options = parseArgs(argv);
1529
+ // #665: both or neither — a partial pair stops the bridge HERE, before any identity or child.
1530
+ const pair = readCodexPair(process.env);
1439
1531
  const { auth, fingerprint } = loadBridgeAuthAndFingerprint();
1440
1532
  // #317 (iii): per-turn outbound tracker (anti-double-post + send_message target).
1441
1533
  const tracker = new OutboundTurnTracker();
@@ -1512,10 +1604,20 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1512
1604
  startConversation: helpers.startConversation,
1513
1605
  requestShutdown: helpers.requestShutdown,
1514
1606
  pauseReason: helpers.pauseReason,
1607
+ pair,
1515
1608
  });
1516
1609
  codex = rt.codex;
1517
1610
  toolHost = rt.toolHost;
1518
1611
  await codex.initialize();
1612
+ // #665: before the control stream opens, so before any join, thread or turn. A refusal is
1613
+ // a deliberate end (presence `ended`), never a crash the runner would read as alive.
1614
+ try {
1615
+ await codex.verifyPair(CODEX_PAIR_CHECK_TIMEOUT_MS);
1616
+ } catch (err) {
1617
+ const stop = new BridgeShutdownError(safeErrorMessage(err), 2);
1618
+ helpers.requestShutdown(stop);
1619
+ throw stop;
1620
+ }
1519
1621
  logCodexSafety();
1520
1622
  },
1521
1623
  onShutdown: () => {
@@ -1570,10 +1672,19 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1570
1672
  if (!shutdown.signal.aborted) shutdown.abort(err);
1571
1673
  },
1572
1674
  pauseReason: () => gateRef.gate?.reason() ?? REASON_STARTING,
1675
+ pair,
1573
1676
  });
1574
1677
  codex = rt.codex;
1575
1678
  toolHost = rt.toolHost;
1576
- await codex.initialize();
1679
+ // #665: before the presence record and the stream, so before any turn. This runs BEFORE the
1680
+ // try/finally below, so a failure must release what is already held (Codex-2 review of d6d127a5).
1681
+ const started = codex;
1682
+ const host = toolHost;
1683
+ await startCheckedCodex(started, CODEX_PAIR_CHECK_TIMEOUT_MS, () => {
1684
+ started.close();
1685
+ host.stop();
1686
+ res.bridgeLock.release();
1687
+ });
1577
1688
  logCodexSafety();
1578
1689
  process.stderr.write(`${LOG_PREFIX} ready: joined ${res.minted.conversation_id} as instance ${res.instanceKey.slice(0, 8)}\n`);
1579
1690
  // #315: no control stream here, so beat the instance liveness directly.