viber-channel 0.8.31 → 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
@@ -64,7 +64,7 @@ export function capabilitiesText(channelVersion: string | null = null): string {
64
64
  " project token); the web's New team / Add agent use the same templates. No local",
65
65
  " file, no built-in, no cache, no fallback: web unreachable, token refused or name",
66
66
  " unknown = an error, and NOTHING is launched (an unknown name lists the available",
67
- " ones). A leftover .viber/agent-teams.json is IGNORED (launch-team says so).",
67
+ " ones).",
68
68
  " A template the spawn would refuse (its warnings, see below) is refused up front.",
69
69
  " Output is verbose; only these lines matter:",
70
70
  " team: verdict COMPLETE|INCOMPLET — N member(s)",
@@ -80,8 +80,10 @@ export function capabilitiesText(channelVersion: string | null = null): string {
80
80
  " launch-<32 hex> (e.g. launch-03f927dc283f94f521b06ee573a9f582); from Viber app",
81
81
  " 0.7.22 on, the bare 32 hex is accepted too (older: pass the launch- prefix);",
82
82
  " read it on the \"member launch-…: launched\" line.",
83
- " ONLINE DELAY: up to ~45 s after the agent has started, plus the runtime boot",
84
- " (runner beat every 30 s + coverage re-read every 15 s). An empty list_agents right",
83
+ " ONLINE DELAY: about 25-35 s from launch-team to online (one agent ~25 s,",
84
+ " three at once ~35 s), nearly all of it the runtime boot; the runner reports a",
85
+ " new agent within ~4 s of its presence record being written. An older Viber app waits for its",
86
+ " 30 s beat instead: up to ~60 s. An empty list_agents right",
85
87
  " after launching is NOT a failure. Wait simply: call list_agents with",
86
88
  " label_prefix \"<prefix>-\" and online: true every ~10 s, for at most ~60 s; past",
87
89
  " that, check that the Viber app is running.",
@@ -94,8 +96,9 @@ export function capabilitiesText(channelVersion: string | null = null): string {
94
96
  " → agent \"g1-dev\", team \"g1-solo\"",
95
97
  " runtime: claude|codex|gemma · permission: read-only|read-write (gemma: read-only",
96
98
  " only; all roles of one template must share one permission) · count optional;",
97
- " model/effort: leave them OUT (runtime default) UNLESS the owner explicitly asks",
98
- " 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.",
99
102
  " (On the web, sidebar ⋯ → Add agent launches ONE named agent without a template edit.)",
100
103
  "",
101
104
  "DISSOLVE (stops the processes, then revokes the proven-stopped instances):",
@@ -143,11 +146,16 @@ export function capabilitiesText(channelVersion: string | null = null): string {
143
146
  " runtime REQUIRED claude | codex | gemma",
144
147
  " permission REQUIRED read-only | read-write (gemma: read-only only)",
145
148
  " rolePrompt REQUIRED non-empty, ≤ 600 chars AFTER {prefix} expands to 32 chars",
146
- " model optional claude: opus | sonnet | haiku | fable (a family, never a",
147
- " version); other runtimes: ≤ 64, stored but not applied",
148
- " effort optional claude only: low | medium | high | xhigh | max",
149
- " model/effort: leave them OUT (runtime default) unless the",
150
- " 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",
151
159
  " count optional integer 1..10",
152
160
  " name: non-empty, ≤ 80, unique per owner, case-insensitive (409 without code = taken).",
153
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
+ }
@@ -8,7 +8,8 @@
8
8
  * <presenceDir>/<instance_id>.coverage.json
9
9
  * { lease_active: true|false|null, beat_id, conversation_id, written_at }
10
10
  *
11
- * Every 15 s this module re-reads it (a local read, zero network) and decides:
11
+ * Every 15 s this module re-reads it (a local read, zero network) — and at once when the file's
12
+ * mtime changes (#663, checked every second, also local) — and decides:
12
13
  *
13
14
  * - fresh coverage, lease true → ACTIVE;
14
15
  * - lease false → the conversation was taken over: the
@@ -31,7 +32,7 @@
31
32
  * Freshness is judged on the LOCAL clock of the coverage write (`written_at`),
32
33
  * never on the server's beat id.
33
34
  */
34
- import { readFileSync } from "node:fs";
35
+ import { readFileSync, statSync } from "node:fs";
35
36
  import { DEFAULT_MAX_INDETERMINATE_LEASE_BEATS } from "./heartbeat.js";
36
37
  import { runnerStatePath, readRunnerState } from "./runner_registry.js";
37
38
  import { coveragePath } from "./runner_roster.js";
@@ -149,6 +150,26 @@ export interface CoverageWatchOptions {
149
150
  log?: (line: string) => void;
150
151
  setTimer?: (fn: () => void, ms: number) => unknown;
151
152
  clearTimer?: (handle: unknown) => void;
153
+ /** #663: how often the coverage file's mtime is checked (a local stat, no network). */
154
+ changeCheckMs?: number;
155
+ /** #663: the coverage file's mtime in ms, or `null` when absent/unreadable (tests inject it). */
156
+ coverageMtime?: (path: string) => number | null;
157
+ }
158
+
159
+ /**
160
+ * #663 — the agent re-read its coverage every 15 s, so after the runner's beat it could stay
161
+ * PAUSED (online on the web, yet unable to send or receive) for up to 15 s more. A cheap local
162
+ * check of the file's mtime now re-reads it as soon as the runner rewrites it. The 15 s re-read
163
+ * stays: it is what pauses an agent whose coverage went STALE, which no write announces.
164
+ */
165
+ export const COVERAGE_CHANGE_CHECK_MS = 1_000;
166
+
167
+ function fileMtime(path: string): number | null {
168
+ try {
169
+ return statSync(path).mtimeMs;
170
+ } catch {
171
+ return null;
172
+ }
152
173
  }
153
174
 
154
175
  export interface CoverageWatch {
@@ -236,9 +257,20 @@ export function startCoverageWatch(opts: CoverageWatchOptions): CoverageWatch {
236
257
 
237
258
  tick();
238
259
  const timer = setTimer(tick, opts.rereadMs ?? COVERAGE_REREAD_MS);
260
+ const mtimeOf = opts.coverageMtime ?? fileMtime;
261
+ let lastMtime = mtimeOf(covPath);
262
+ const changeTimer = setTimer(() => {
263
+ const m = mtimeOf(covPath);
264
+ if (m === lastMtime) return;
265
+ lastMtime = m;
266
+ if (m !== null) tick();
267
+ }, opts.changeCheckMs ?? COVERAGE_CHANGE_CHECK_MS);
239
268
  return {
240
269
  gate,
241
270
  tick,
242
- stop: () => clearTimer(timer),
271
+ stop: () => {
272
+ clearTimer(timer);
273
+ clearTimer(changeTimer);
274
+ },
243
275
  };
244
276
  }
@@ -4,7 +4,8 @@
4
4
  * ## ⚠ WHY THIS IS A SEPARATE CONTRACT AND NOT AN EXTENSION OF `spawn_reason.ts`
5
5
  *
6
6
  * JP's decision of 2026-08-27: the gateway's result line is a marker of its OWN. ▶ Widening
7
- * `parseSpawnResult` to also accept a nonce-less or differently-shaped payload would have reopened
7
+ * `parseSpawnResult` (the `vibe-master team spawn` reader, removed in #662 once #660 had removed
8
+ * its emitter) to also accept a nonce-less or differently-shaped payload would have reopened
8
9
  * "the last valid marker wins" for `vibe-master`'s marker too — one relaxation, two contracts
9
10
  * weakened. ⚠ So the runner learns a SECOND reader rather than loosening the first.
10
11
  *
@@ -19,8 +20,8 @@
19
20
  * ```
20
21
  *
21
22
  * ▶▶ So a HALF-LAUNCHED team exits `0`, and a consumer deriving success from the exit code reports
22
- * `succeeded` — the browser then says "team spawn completed" while members are missing. ⚠ A
23
- * reason-only marker does not close that: it would be ABSENT on a `0`-exit INCOMPLET team, and an
23
+ * `succeeded` — the browser then said "team spawn completed" (now "team launch completed")
24
+ * while members were missing. ⚠ A reason-only marker does not close that: it would be ABSENT on a `0`-exit INCOMPLET team, and an
24
25
  * absence is indistinguishable from a success.
25
26
  *
26
27
  * ⚠ The gateway's exit codes are NOT the defect and are not touched: `0` there means *a result was
@@ -28,7 +29,7 @@
28
29
  *
29
30
  * ## ▶ THE FALLBACK IS A FAILURE, NEVER A SUCCESS
30
31
  *
31
- * `parseSpawnResult` returns `null` for "no marker" and the caller *degrades to the previous
32
+ * `parseSpawnResult` returned `null` for "no marker" and its caller *degraded to the previous
32
33
  * message* — correct there, because that path's success was already established by the exit code.
33
34
  * ⚠ **Here the exit code cannot establish success**, so a `null` must not be readable as one. Rules
34
35
  * entry 10: a fallback is only legitimate when its value is IMPOSSIBLE to confuse with a measurement.
@@ -36,8 +37,8 @@
36
37
 
37
38
  import { SPAWN_REASONS, type SpawnReason } from "./spawn_reason.js";
38
39
 
39
- /** Mirrors `../../viber-gateway/lib/result_marker.ts`. ⚠ Named separately from `VIBEMASTER_RESULT` so
40
- * neither reader can be satisfied by the other's line. */
40
+ /** Mirrors `../../viber-gateway/lib/result_marker.ts`. ⚠ Named separately from the former
41
+ * `VIBEMASTER_RESULT` so that no reader could be satisfied by the other's line. */
41
42
  const GATEWAY_MARKER = "VIBERGATEWAY_RESULT";
42
43
 
43
44
  /** Bounded at both ends of the pipe, like `vibe-master`'s detail. */
@@ -0,0 +1,112 @@
1
+ /**
2
+ * presence_trigger.ts — #663: the runner beats when an agent APPEARS, not only on its 30 s tick.
3
+ *
4
+ * Measured (plan 663, step-01): an agent's presence record is on disk ~21 s after launch, and the
5
+ * runner then waited for the next tick of its 30 s beat — 3 to 26 s of pure waiting on 8 launches.
6
+ *
7
+ * JP's constraint (2026-10-01): **no more network than today when nothing changes.** So this module
8
+ * is LOCAL only: it re-lists the presence folder every `intervalMs` (a directory read and one stat
9
+ * per record — no request), and calls `onChange` when a record is NEW or REWRITTEN (an agent writes
10
+ * its record at registration, then again when its start time becomes known — the runner skips a
11
+ * record without one, so that rewrite must trigger too). The caller turns `onChange` into ONE extra
12
+ * beat, the same request as the periodic one.
13
+ *
14
+ * - **Deletions do not trigger.** The beat itself deletes the records the server acknowledged as
15
+ * ended; reacting to that would make every such beat schedule another one.
16
+ * - **Coverage files and the `runner/` folder are ignored**: the beat writes them after each
17
+ * acknowledgement, so they must not count as a change either.
18
+ * - **Debounced**: a team of three writes three records within a second — one beat, not three.
19
+ * - The first scan is the BASELINE: records already there when the runner starts are covered by the
20
+ * runner's own start-up beat.
21
+ *
22
+ * ⚠ Why polling and not `fs.watch` (Opus, plan review): on Windows `fs.watch` duplicates events and
23
+ * reports an atomic write (tmp + rename) as a `rename`; a 2 s directory read is cheap and exact.
24
+ */
25
+ import { readdirSync, statSync } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ export const PRESENCE_SCAN_MS = 2_000;
29
+ export const PRESENCE_DEBOUNCE_MS = 500;
30
+
31
+ export interface PresenceTriggerOptions {
32
+ dir: string;
33
+ onChange: () => void;
34
+ intervalMs?: number;
35
+ debounceMs?: number;
36
+ /** Record file name → mtime (ms). `null` = folder unreadable or absent (treated as empty). */
37
+ list?: (dir: string) => Map<string, number> | null;
38
+ setInterval?: (fn: () => void, ms: number) => unknown;
39
+ clearInterval?: (handle: unknown) => void;
40
+ setTimeout?: (fn: () => void, ms: number) => unknown;
41
+ clearTimeout?: (handle: unknown) => void;
42
+ }
43
+
44
+ export interface PresenceTrigger {
45
+ /** One scan now (tests, and nothing else needs it). */
46
+ scan: () => void;
47
+ stop: () => void;
48
+ }
49
+
50
+ /** The agent records of a presence folder: `<instance_id>.json`, never `*.coverage.json`. */
51
+ export function listPresenceRecords(dir: string): Map<string, number> | null {
52
+ let names: string[];
53
+ try {
54
+ names = readdirSync(dir);
55
+ } catch {
56
+ return null;
57
+ }
58
+ const out = new Map<string, number>();
59
+ for (const name of names) {
60
+ if (!name.endsWith(".json") || name.endsWith(".coverage.json")) continue;
61
+ try {
62
+ out.set(name, statSync(join(dir, name)).mtimeMs);
63
+ } catch {
64
+ // Deleted between the listing and the stat: a deletion, which never triggers.
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ export function startPresenceTrigger(opts: PresenceTriggerOptions): PresenceTrigger {
71
+ const list = opts.list ?? listPresenceRecords;
72
+ const setIv = opts.setInterval ?? ((fn: () => void, ms: number) => unrefd(setInterval(fn, ms)));
73
+ const clearIv = opts.clearInterval ?? ((h: unknown) => clearInterval(h as ReturnType<typeof setInterval>));
74
+ const setTo = opts.setTimeout ?? ((fn: () => void, ms: number) => unrefd(setTimeout(fn, ms)));
75
+ const clearTo = opts.clearTimeout ?? ((h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>));
76
+ const debounceMs = opts.debounceMs ?? PRESENCE_DEBOUNCE_MS;
77
+
78
+ let known: Map<string, number> = list(opts.dir) ?? new Map();
79
+ let pending: unknown = null;
80
+ let stopped = false;
81
+
82
+ const scan = (): void => {
83
+ if (stopped) return;
84
+ const now = list(opts.dir) ?? new Map<string, number>();
85
+ let changed = false;
86
+ for (const [name, mtime] of now) {
87
+ if (known.get(name) !== mtime) changed = true;
88
+ }
89
+ known = now;
90
+ if (!changed || pending !== null) return;
91
+ pending = setTo(() => {
92
+ pending = null;
93
+ if (!stopped) opts.onChange();
94
+ }, debounceMs);
95
+ };
96
+
97
+ const timer = setIv(scan, opts.intervalMs ?? PRESENCE_SCAN_MS);
98
+ return {
99
+ scan,
100
+ stop: () => {
101
+ stopped = true;
102
+ clearIv(timer);
103
+ if (pending !== null) clearTo(pending);
104
+ pending = null;
105
+ },
106
+ };
107
+ }
108
+
109
+ function unrefd<T>(h: T): T {
110
+ (h as { unref?: () => void }).unref?.();
111
+ return h;
112
+ }
@@ -5,9 +5,10 @@
5
5
  * Flow (per claimed command): validate the SERVER-VALIDATED role snapshot
6
6
  * against the machine's local DEFAULT-DENY policy (permissions = template ∩
7
7
  * local, never widened — C3) → report `starting` → shell
8
- * `vibe-master team spawn` via an argv ARRAY (execFile, never a shell string —
9
- * no injection) → report `succeeded`/`failed` (fencing-guarded). A 409 report
10
- * (re-claim/cancel) → cooperative abort (don't overwrite a newer outcome — C4).
8
+ * `viber-gateway team` via an argv ARRAY (execFile, never a shell string —
9
+ * no injection), which invokes `vibe-master spawn` once per member → report
10
+ * `succeeded`/`failed` (fencing-guarded). A 409 report (re-claim/cancel) →
11
+ * cooperative abort (don't overwrite a newer outcome — C4).
11
12
  *
12
13
  * Dependency-injected: `runTeamSpawn` (the launcher) and `apiFetch` are
13
14
  * injectable so the flow is unit-testable without shelling or a live server.
@@ -19,7 +20,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
19
20
  import { join } from "node:path";
20
21
  import { cfAccessHeaders } from "./cfAccess.js";
21
22
  import { gatewayNothingToDo, gatewaySpawnOk, parseGatewayResult } from "./gateway_result.js";
22
- import { messageForReason, parseSpawnResult, rosterNames, type SpawnReason } from "./spawn_reason.js";
23
+ import { messageForReason, rosterNames, type SpawnReason } from "./spawn_reason.js";
23
24
  import { identityBaseUrl } from "./base_urls.js";
24
25
  import { recordSpawn, registryPath, type SpawnForRegistry, updateRegistry } from "./runner_registry.js";
25
26
  import { relaunchInstance } from "./runner_relaunch.js";
@@ -246,12 +247,10 @@ export type TeamSpawnRunner = (
246
247
  * ⚠⚠ #590: **the LAUNCHER states whether the desired state was already in place** — the reader
247
248
  * no longer infers it from a bare `reason` string.
248
249
  *
249
- * ▶ The same word reaches the reader from two launchers with two DIFFERENT guards, and the value
250
- * that crossed this boundary carried neither the provenance nor the proof
251
- * (`gwl10-review-codex-2`). Each runner now proves its own claim: the gateway through
252
- * {@link gatewayNothingToDo} (roster coverage **and** verdict), `vibe-master` through
253
- * `classifyCollision`, which emitted this word only when NO roster member was missing
254
- * (removed in #660 with `vibe-master team spawn`; no vibe-master command emits it now).
250
+ * ▶ The value that crossed this boundary used to carry neither the provenance nor the proof
251
+ * (`gwl10-review-codex-2`). The launcher now proves its own claim: the gateway through
252
+ * {@link gatewayNothingToDo} (roster coverage **and** verdict). It is the only launcher that
253
+ * makes one since #660 removed `vibe-master team spawn`.
255
254
  *
256
255
  * ⚠ **OPTIONAL, and the reader FAILS CLOSED on absence** (`?? false`): an omitted field is *no
257
256
  * claim*, never *nothing to do*. ▶ This is deliberately unlike the `nonce` above, which is
@@ -311,10 +310,12 @@ export function vibeMasterBaseCmd(source: NodeJS.ProcessEnv = process.env): stri
311
310
  return parts.length > 0 ? parts : ["vibe-master"];
312
311
  }
313
312
 
314
- /** Default launcher: shell vibe-master via execFile with an ARGV ARRAY (never a
315
- * shell string), a CURATED env, bounded time + output, and process-tree kill on
316
- * timeout. The binary is resolved via VIBE_MASTER_CMD (default `vibe-master`). */
317
- export const execFileTeamSpawn: TeamSpawnRunner = (args, nonce) => {
313
+ /** The single-agent launcher (relaunch path): shell `vibe-master spawn` via execFile
314
+ * with an ARGV ARRAY (never a shell string), a CURATED env, bounded time + output,
315
+ * and process-tree kill on timeout. The binary is resolved via VIBE_MASTER_CMD
316
+ * (default `vibe-master`). `spawn` writes no outcome marker, so the exit code is
317
+ * the whole verdict — the gateway path, which has one, is {@link execFileGatewayTeamSpawn}. */
318
+ export const execFileVibeMasterSpawn = (args: string[]): Promise<{ ok: boolean; recap: string }> => {
318
319
  const base = vibeMasterBaseCmd();
319
320
  const bin = base[0] as string;
320
321
  return new Promise((resolve) => {
@@ -323,28 +324,7 @@ export const execFileTeamSpawn: TeamSpawnRunner = (args, nonce) => {
323
324
  [...base.slice(1), ...args],
324
325
  { timeout: 5 * 60 * 1000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, env: buildChildEnv() },
325
326
  (err, stdout, stderr) => {
326
- // #496: read the outcome marker from RAW stderr, BEFORE redaction and
327
- // before the 4 000-char cut. The marker is written last, so it is the
328
- // first thing `.slice()` drops on a real (verbose) roster — parsing the
329
- // recap would pass every test and fail every actual spawn. Redaction can
330
- // also mangle the JSON. Both hazards disappear by reading first.
331
- const parsed = parseSpawnResult(stderr ?? "", { nonce });
332
- const recap = redactSecrets(`${stdout}\n${stderr}`.trim()).slice(0, 4000);
333
- resolve({
334
- ok: !err,
335
- // ▶ #590: vibe-master's OWN guarantee, stated by the launcher that holds it.
336
- // ⚠ MEASURED before #660 (`vibe-master/lib/teams.ts`, `classifyCollision`, removed with
337
- // `team spawn`; `spawn` never emits a marker, so this now only reads an absent one): this word was emitted only
338
- // when `missing.length === 0` — every roster name live — and a non-colliding call returns
339
- // `null` before it, so an empty roster cannot produce it either. A partial team is
340
- // `partial_team` and stays a failure. ▶ **The same roster-coverage property as the
341
- // gateway's, by different code** — so promoting only the gateway path would turn a
342
- // CORRECT de-duplication here back into a red, which is the defect JP arbitrated away.
343
- nothingToDo: parsed?.reason === "already_running",
344
- recap,
345
- ...(parsed?.reason ? { reason: parsed.reason } : {}),
346
- ...(parsed?.detail ? { detail: parsed.detail } : {}),
347
- });
327
+ resolve({ ok: !err, recap: redactSecrets(`${stdout}\n${stderr}`.trim()).slice(0, 4000) });
348
328
  },
349
329
  );
350
330
  });
@@ -524,7 +504,7 @@ export function buildGatewayTeamArgs(cmd: {
524
504
  /**
525
505
  * The gateway launcher. ⚠⚠ **`ok` is derived from the MARKER, never from the exit
526
506
  * code**, and that is the whole reason this function exists beside
527
- * {@link execFileTeamSpawn}.
507
+ * {@link execFileVibeMasterSpawn}.
528
508
  *
529
509
  * ```
530
510
  * viber-gateway/lib/team_spawn_command.ts:358
@@ -532,7 +512,7 @@ export function buildGatewayTeamArgs(cmd: {
532
512
  * ```
533
513
  *
534
514
  * ▶▶ So `ok = !err` would report `succeeded` on a HALF-LAUNCHED team, and the
535
- * browser would say *team spawn completed* while members are missing. ⚠ The
515
+ * browser would say *team launch completed* while members are missing. ⚠ The
536
516
  * gateway's exit codes are right — `0` means *a result was produced* — and are not
537
517
  * touched; what changes is that this consumer stops reading that as *the team is
538
518
  * there*.
@@ -758,7 +738,7 @@ async function report(
758
738
 
759
739
  /**
760
740
  * Execute one already-claimed command. Validates against local policy, reports
761
- * starting → runs the team spawn → reports the outcome (fencing-guarded). A 409
741
+ * starting → runs the team launch → reports the outcome (fencing-guarded). A 409
762
742
  * on the `starting` report means the claim is no longer ours (re-claim/cancel)
763
743
  * → cooperative abort BEFORE spawning (never launch on a lost claim). Returns
764
744
  * the terminal status the runner recorded (or "aborted").
@@ -768,11 +748,9 @@ export async function executeClaim(
768
748
  cmd: ClaimedCommand,
769
749
  deps: ExecDeps = {},
770
750
  ): Promise<"succeeded" | "failed" | "aborted"> {
771
- // ⚠⚠ THE DEFAULT IS THE GATEWAY NOW — that swap IS the junction, and leaving
772
- // `execFileTeamSpawn` as the default behind a flag would have been a mechanism
773
- // delivered without a junction, the exact shape this workshop has paid for twice.
774
- // ▶ `execFileTeamSpawn` stays EXPORTED: it is still the launcher for anything that
775
- // talks to `vibe-master` directly, and its witnesses keep measuring that contract.
751
+ // ⚠⚠ THE DEFAULT IS THE GATEWAY — that swap IS the junction (#590). The only
752
+ // direct `vibe-master` launcher left is the single-agent relaunch
753
+ // ({@link execFileVibeMasterSpawn}).
776
754
  const runTeamSpawn = deps.runTeamSpawn ?? execFileGatewayTeamSpawn;
777
755
  const fetchImpl = deps.apiFetch ?? fetch;
778
756
  if (cmd.kind === "relaunch") return executeRelaunchClaim(auth, cmd, deps, fetchImpl);
@@ -860,8 +838,9 @@ export async function executeClaim(
860
838
  if (startingStatus < 200 || startingStatus >= 300) return "aborted";
861
839
 
862
840
  // P1-1: execute EXACTLY the server-validated snapshot. Serialize the already-
863
- // validated roles to a temp spec file and invoke `team spawn --spec-file` — so
864
- // vibe-master runs THOSE roles, never a name-resolved local template. Serialize
841
+ // validated roles to a temp spec file and hand it to `viber-gateway team
842
+ // --spec-file` — so the gateway launches THOSE roles, never a name-resolved
843
+ // template. Serialize
865
844
  // the validated object verbatim (no role rebuild). Cleaned up in `finally`.
866
845
  const spec = { name: cmd.template_name, roles: cmd.template_spec.roles };
867
846
  const { path: specPath, cleanup } = (deps.writeSpec ?? fsWriteSpec())(spec);
@@ -1004,7 +983,7 @@ export async function executeClaim(
1004
983
  auth,
1005
984
  cmd.id,
1006
985
  ok
1007
- ? { fencing_token: cmd.fencing_token, status: "succeeded", result: { message: "team spawn completed", log: logPath } }
986
+ ? { fencing_token: cmd.fencing_token, status: "succeeded", result: { message: "team launch completed", log: logPath } }
1008
987
  : nothingToDo
1009
988
  ? {
1010
989
  fencing_token: cmd.fencing_token,
@@ -1031,8 +1010,8 @@ export async function executeClaim(
1031
1010
  logRef,
1032
1011
  )
1033
1012
  : logRef
1034
- ? `team spawn failed — see ${logRef} on that machine`
1035
- : "team spawn failed, and the local log could not be written on that machine",
1013
+ ? `team launch failed — see ${logRef} on that machine`
1014
+ : "team launch failed, and the local log could not be written on that machine",
1036
1015
  ...(reason && !nameUnconfirmed ? { reason } : {}),
1037
1016
  },
1038
1017
  fetchImpl,
@@ -1081,7 +1060,7 @@ export async function reconcileOnce(auth: RunnerAuthLite, deps: ExecDeps = {}):
1081
1060
  /**
1082
1061
  * #627 step-07 — a RELAUNCH command: one agent, from this runner's registry. The
1083
1062
  * decision (refuse / re-attach / launch) is `relaunchInstance`'s; this reports it with
1084
- * the same fencing-guarded reports as a team spawn. No automatic retry on failure.
1063
+ * the same fencing-guarded reports as a team launch. No automatic retry on failure.
1085
1064
  */
1086
1065
  async function executeRelaunchClaim(
1087
1066
  auth: RunnerAuthLite,
@@ -1121,7 +1100,7 @@ async function executeRelaunchClaim(
1121
1100
  identity: runnerIdentity(auth),
1122
1101
  ...(deps.relaunchLockDir !== undefined ? { lockDir: deps.relaunchLockDir } : {}),
1123
1102
  ...(deps.relaunchEnumerate !== undefined ? { enumerate: deps.relaunchEnumerate } : {}),
1124
- runSpawn: deps.runSingleSpawn ?? ((args) => execFileTeamSpawn(args, randomBytes(8).toString("hex"))),
1103
+ runSpawn: deps.runSingleSpawn ?? execFileVibeMasterSpawn,
1125
1104
  checkPolicy: (entry) =>
1126
1105
  validateAgainstPolicy(
1127
1106
  { ...cmd, env: entry.env, template_spec: { roles: [{ ...entry.role }] } },
@@ -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
 
@@ -350,23 +350,47 @@ export interface RosterBeatDeps {
350
350
  * A failed or non-2xx beat changes NOTHING on disk: the coverage then expires on its
351
351
  * own and the ended records go again next beat.
352
352
  */
353
- export async function runRosterBeat(auth: RosterBeatAuth, deps: RosterBeatDeps = {}): Promise<"ok" | "revoked"> {
353
+ export function runRosterBeat(
354
+ auth: RosterBeatAuth,
355
+ deps: RosterBeatDeps = {},
356
+ opts: { queueIfBusy?: boolean } = {},
357
+ ): Promise<"ok" | "revoked"> {
354
358
  // SINGLE-FLIGHT. The enumeration alone can take up to 30 s — one heartbeat period —
355
359
  // so an overlapping beat is ordinary, and two answers arriving in reverse order
356
- // would rewrite an older coverage over a newer one (Codex, step-03 review). A tick
357
- // that finds a beat in flight is skipped: the running one covers it.
358
- if (beatInFlight) {
359
- noteOnce("a heartbeat was skipped: the previous one is still in flight", deps.log ?? ((l: string) => process.stderr.write(`${l}\n`)));
360
- return "ok";
361
- }
362
- beatInFlight = true;
363
- try {
364
- return await rosterBeatOnce(auth, deps);
365
- } finally {
366
- beatInFlight = false;
360
+ // would rewrite an older coverage over a newer one (Codex, step-03 review). A
361
+ // PERIODIC tick that finds a beat in flight is skipped: the running one covers it.
362
+ if (beatInFlight !== null) {
363
+ if (!opts.queueIfBusy) {
364
+ noteOnce("a heartbeat was skipped: the previous one is still in flight", deps.log ?? ((l: string) => process.stderr.write(`${l}\n`)));
365
+ return Promise.resolve("ok");
366
+ }
367
+ // #663: a TRIGGERED beat (a presence record appeared or changed) is not dropped: the beat in
368
+ // flight may have collected its roster BEFORE that record existed, so skipping it would leave
369
+ // the agent waiting for the next 30 s tick — the very delay the trigger removes. ONE follow-up
370
+ // beat at most, whatever the number of triggers while busy (Codex, plan review).
371
+ // ⚠ Chained on the beat's OUTCOME, success or rejection (Opus + Codex review): chained on
372
+ // success only, a rejected beat left `queuedBeat` pinned to a dead promise for good, and every
373
+ // later trigger silently lost its follow-up. A `revoked` answer cancels the follow-up (Codex).
374
+ const followUp = (previous: "ok" | "revoked" | undefined): Promise<"ok" | "revoked"> => {
375
+ queuedBeat = null;
376
+ if (previous === "revoked") return Promise.resolve("revoked");
377
+ return runRosterBeat(auth, deps, { queueIfBusy: true });
378
+ };
379
+ queuedBeat ??= beatInFlight.then(followUp, () => followUp(undefined));
380
+ return queuedBeat;
367
381
  }
382
+ const run = (async () => {
383
+ try {
384
+ return await rosterBeatOnce(auth, deps);
385
+ } finally {
386
+ beatInFlight = null;
387
+ }
388
+ })();
389
+ beatInFlight = run;
390
+ return run;
368
391
  }
369
- let beatInFlight = false;
392
+ let beatInFlight: Promise<"ok" | "revoked"> | null = null;
393
+ let queuedBeat: Promise<"ok" | "revoked"> | null = null;
370
394
  let unappliedBeats = 0;
371
395
  /** #627 (decision A): pending beats before the runner says the handover is slow. */
372
396
  export const HANDOVER_WARN_AFTER = 10;
@@ -19,8 +19,10 @@ import { randomBytes } from "node:crypto";
19
19
  import { join } from "node:path";
20
20
  import { cfAccessHeaders } from "./cfAccess.js";
21
21
  import { isTrustedViberOrigin } from "./urls.js";
22
- import { type ExecDeps, reconcileOnce } from "./runner_exec.js";
22
+ import { type ExecDeps, reconcileOnce, runnerIdentity } from "./runner_exec.js";
23
23
  import { runRosterBeat } from "./runner_roster.js";
24
+ import { presenceDir } from "./presence_record.js";
25
+ import { startPresenceTrigger } from "./presence_trigger.js";
24
26
 
25
27
  /** Remove leftover temp spec files (`.viber/runner-specs/`) from a prior crash
26
28
  * at daemon start — they may hold sensitive rolePrompts (Codex P1-1). The
@@ -127,8 +129,8 @@ async function safeReconcile(auth: RunnerAuth, deps: ExecDeps = {}): Promise<voi
127
129
  *
128
130
  * #627: the beat now carries the machine's ROSTER — the only presence signal of
129
131
  * the agents this runner vouches for (`runner_roster.ts`). */
130
- async function sendHeartbeat(auth: RunnerAuth): Promise<"ok" | "revoked"> {
131
- return runRosterBeat(auth, { headers: cfAccessHeaders });
132
+ async function sendHeartbeat(auth: RunnerAuth, queueIfBusy = false): Promise<"ok" | "revoked"> {
133
+ return runRosterBeat(auth, { headers: cfAccessHeaders }, { queueIfBusy });
132
134
  }
133
135
 
134
136
  export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
@@ -172,15 +174,28 @@ export async function runPersistentRunnerStream(
172
174
  // Shared abort so a heartbeat-detected revocation tears down the live SSE now,
173
175
  // instead of waiting for the next reconnect.
174
176
  let currentAbort: AbortController | null = null;
175
- const heartbeatTimer = setInterval(() => {
176
- void sendHeartbeat(auth).then((r) => {
177
- if (r === "revoked") {
178
- revoked = true;
179
- stopped = true;
180
- currentAbort?.abort();
181
- }
182
- });
183
- }, HEARTBEAT_INTERVAL_MS);
177
+ const beat = (queueIfBusy: boolean): void => {
178
+ void sendHeartbeat(auth, queueIfBusy)
179
+ .then((r) => {
180
+ if (r === "revoked") {
181
+ revoked = true;
182
+ stopped = true;
183
+ currentAbort?.abort();
184
+ }
185
+ })
186
+ // A beat that throws (process enumeration, a disk read) is logged, never an unhandled
187
+ // rejection — and the next tick or trigger beats again (Opus review).
188
+ .catch((err) => process.stderr.write(`[runner] heartbeat error (non-fatal): ${String(err)}\n`));
189
+ };
190
+ const heartbeatTimer = setInterval(() => beat(false), HEARTBEAT_INTERVAL_MS);
191
+ // #663: one beat at START (agents already on the machine are covered at once, not after up to
192
+ // 30 s), then one more whenever a presence record appears or is rewritten — a LOCAL folder scan,
193
+ // no request unless something changed. The 30 s tick is unchanged.
194
+ beat(false);
195
+ const presenceTrigger = startPresenceTrigger({
196
+ dir: presenceDir(runnerIdentity(auth)),
197
+ onChange: () => beat(true),
198
+ });
184
199
  // Reconcile timer is a RE-JITTERED recursive setTimeout (not setInterval): each
185
200
  // tick draws a fresh crypto jitter so a fleet never re-synchronizes (Codex C6).
186
201
  let reconcileTimer: ReturnType<typeof setTimeout>;
@@ -231,6 +246,7 @@ export async function runPersistentRunnerStream(
231
246
  }
232
247
  } finally {
233
248
  clearInterval(heartbeatTimer);
249
+ presenceTrigger.stop();
234
250
  clearTimeout(reconcileTimer!);
235
251
  }
236
252
  }
@@ -7,20 +7,19 @@
7
7
  * had nowhere to go. The cause existed — it was on the runner's stderr — and was
8
8
  * thrown away here.
9
9
  *
10
- * `vibe-master team spawn` emitted a machine line (`VIBEMASTER_RESULT {"reason":…}`);
11
- * #660 removed that command and its emitter. The live emitter of a team outcome is
12
- * now `viber-gateway/lib/result_marker.ts` (`VIBERGATEWAY_RESULT`, read by
13
- * `gateway_result.ts`); the reasons below remain its vocabulary. This module
14
- * extracts the old line and turns a reason into a sentence the web can show. The runner's OWN refusals (policy,
10
+ * The emitter of a team outcome is `viber-gateway/lib/result_marker.ts`
11
+ * (`VIBERGATEWAY_RESULT`, read by `gateway_result.ts`); the reasons below are its
12
+ * vocabulary. This module turns a reason into a sentence the web can show. The
13
+ * runner's OWN refusals (policy,
15
14
  * unsafe input, a prior partial run) carry reasons from the same vocabulary, so
16
15
  * the web never has to speak two languages.
17
16
  */
18
17
 
19
- /** Reasons a spawn can be refused. Mirrors vibe-master's vocabulary plus the
20
- * four the runner produces itself. The API keeps its own allowlist: an unknown
18
+ /** Reasons a spawn can be refused. The launcher's vocabulary (emitted by
19
+ * `viber-gateway/lib/result_marker.ts`) plus the four the runner produces itself. The API keeps its own allowlist: an unknown
21
20
  * reason is dropped there, never stored raw — it comes from a client machine. */
22
21
  export const SPAWN_REASONS = [
23
- // Emitted by vibe-master
22
+ // Emitted by the launcher (viber-gateway)
24
23
  "already_running",
25
24
  "partial_team",
26
25
  "template_invalid",
@@ -39,18 +38,6 @@ export const SPAWN_REASONS = [
39
38
 
40
39
  export type SpawnReason = (typeof SPAWN_REASONS)[number];
41
40
 
42
- const MARKER = "VIBEMASTER_RESULT";
43
-
44
- /** Detail is diagnostic context, never shown raw to a user (the server bounds it
45
- * again and the web renders from the REASON). Bounded here too so a runaway
46
- * child cannot push a megabyte into a report. */
47
- const DETAIL_MAX = 300;
48
-
49
- export interface ParsedSpawnResult {
50
- reason: SpawnReason;
51
- detail?: string;
52
- }
53
-
54
41
  /**
55
42
  * Keep only the names that belong to THIS command's roster.
56
43
  *
@@ -98,72 +85,6 @@ export function rosterNames(
98
85
  return names;
99
86
  }
100
87
 
101
- /**
102
- * Extract the outcome marker from RAW child stderr.
103
- *
104
- * Read this before the recap is built, never after: the recap is redacted and
105
- * then cut to 4 000 characters, and the marker is written LAST — exactly the end
106
- * that the cut removes. A four-member roster overflows that budget easily, so
107
- * parsing the recap would work in tests and fail on a real spawn.
108
- *
109
- * Scans for the LAST line that starts with the marker AND parses AND carries a
110
- * known reason — not simply the last line. On `launch_failed` the marker is
111
- * written after child processes have inherited this same stderr, so trailing
112
- * child output can follow it; and a line quoting the marker inside other text is
113
- * ignored because the prefix must start the line.
114
- *
115
- * Returns null when there is no marker (an older vibe-master, or output lost to
116
- * a maxBuffer overflow) — the caller then behaves exactly as before.
117
- */
118
- export type MarkerTrust =
119
- /** Believe only a marker echoing this nonce (the runner path — the default). */
120
- | { nonce: string }
121
- /** No emitter check at all. Named so a caller cannot fall into it by omission
122
- * (Opus review): an optional nonce silently skipped would reopen "last valid
123
- * marker wins" with no test turning red. Only for reading output nobody
124
- * else could have written into — a human running the CLI, or a unit test. */
125
- | { trustUnauthenticated: true };
126
-
127
- export function parseSpawnResult(
128
- rawStderr: string,
129
- trust: MarkerTrust,
130
- ): ParsedSpawnResult | null {
131
- const expectedNonce = "nonce" in trust ? trust.nonce : undefined;
132
- if (expectedNonce !== undefined && expectedNonce.length === 0) {
133
- throw new Error("parseSpawnResult: empty nonce — pass {trustUnauthenticated:true} to skip the check deliberately");
134
- }
135
- const known = new Set<string>(SPAWN_REASONS);
136
- for (const line of rawStderr.split(/\r?\n/).reverse()) {
137
- if (!line.startsWith(`${MARKER} `)) continue;
138
- let parsed: unknown;
139
- try {
140
- parsed = JSON.parse(line.slice(MARKER.length + 1));
141
- } catch {
142
- continue; // truncated or interleaved — keep looking further back
143
- }
144
- if (typeof parsed !== "object" || parsed === null) continue;
145
- const { reason, detail, nonce } = parsed as {
146
- reason?: unknown;
147
- detail?: unknown;
148
- nonce?: unknown;
149
- };
150
- // Emitter proof. Spawned agents inherit this stderr, and an agent working on
151
- // THIS repo prints this very contract in its own output — "the last valid
152
- // marker wins" would let such a line overrule the real outcome and turn a
153
- // launch_failed into a neutral already_running. Only the process we handed
154
- // the nonce to can echo it.
155
- if (expectedNonce && nonce !== expectedNonce) continue;
156
- if (typeof reason !== "string" || !known.has(reason)) continue;
157
- return {
158
- reason: reason as SpawnReason,
159
- ...(typeof detail === "string" && detail.length > 0
160
- ? { detail: detail.slice(0, DETAIL_MAX) }
161
- : {}),
162
- };
163
- }
164
- return null;
165
- }
166
-
167
88
  /**
168
89
  * The sentence the web shows. Built from the REASON. Where agent names carry the
169
90
  * actionable part ("which member is missing"), they go through
@@ -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.31",
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.