viber-channel 0.8.7 → 0.8.9

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.
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
17
17
  import { createTokenRefreshScheduler, type TokenRefreshScheduler } from "./token_refresh.js";
18
+ import type { ApiBaseUrlResolver } from "./base_urls.js";
18
19
  import { lockFilePath } from "./lockfile.js";
19
20
  import {
20
21
  type ConversationMintResponse,
@@ -46,6 +47,21 @@ export interface ConversationMessage {
46
47
  sender_instance_id?: string | null;
47
48
  }
48
49
 
50
+ /**
51
+ * Persistent cross-run dedup watermark for ONE conversation (#429, mirror of
52
+ * `DmWatermark` in dm_stream.ts). Holds the highest message id already DELIVERED
53
+ * (replied to) in a prior run of this conversation's stream. The caller keeps one
54
+ * per `convId` and passes it back on every re-join, so it SURVIVES the
55
+ * abort+restart cycle `runConversationStream` does on a re-pushed join — unlike
56
+ * the run-scoped `handledIds` (fresh + empty each run). Without it the first
57
+ * await-invite pass re-forwards (re-replies to) the most recent trigger on every
58
+ * re-spawn/rejoin. Mutated in place. `0` = nothing delivered yet.
59
+ */
60
+ export interface BridgeWatermark {
61
+ /** Highest delivered (replied-to) message id (0 = nothing delivered yet). */
62
+ lastId: number;
63
+ }
64
+
49
65
  /** One live conversation stream's mutable runtime state (token rotates). */
50
66
  export interface ConversationRuntime {
51
67
  id: string;
@@ -163,15 +179,21 @@ export function isProcessAlive(pid: number): boolean {
163
179
  * bridge's log lines under its own tag.
164
180
  */
165
181
  export function acquireBridgeLock(opts: {
166
- baseUrl: string;
182
+ /**
183
+ * #480: the LOGICAL backend identity, NOT the API transport host. The lock
184
+ * path must stay stable when agent traffic is redirected, otherwise a running
185
+ * agent stops recognizing its own lock and the #311/#293 duplicate window
186
+ * re-opens. Injected rather than read from the env so this stays pure.
187
+ */
188
+ identityBaseUrl: string;
167
189
  fingerprint: string;
168
190
  sessionId: string;
169
191
  lockDir: string;
170
192
  logPrefix: string;
171
193
  }): BridgeLock {
172
- const { baseUrl, fingerprint, sessionId, lockDir, logPrefix } = opts;
194
+ const { identityBaseUrl, fingerprint, sessionId, lockDir, logPrefix } = opts;
173
195
  mkdirSync(lockDir, { recursive: true });
174
- const path = lockFilePath(baseUrl, fingerprint, sessionId, lockDir);
196
+ const path = lockFilePath(identityBaseUrl, fingerprint, sessionId, lockDir);
175
197
  const release = (): void => {
176
198
  try {
177
199
  unlinkSync(path);
@@ -235,11 +257,12 @@ function refreshLeadSeconds(logPrefix: string): number | undefined {
235
257
  export function startTokenRefresh(opts: {
236
258
  runtime: ConversationRuntime;
237
259
  fingerprint: string;
238
- baseUrl: string;
260
+ /** #480 — long-lived scheduler: read the host per refresh, never capture it. */
261
+ getBaseUrl: () => string;
239
262
  requestShutdown: (err: BridgeShutdownError) => void;
240
263
  logPrefix: string;
241
264
  }): TokenRefreshScheduler {
242
- const { runtime, fingerprint, baseUrl, requestShutdown, logPrefix } = opts;
265
+ const { runtime, fingerprint, getBaseUrl, requestShutdown, logPrefix } = opts;
243
266
  const leadSeconds = refreshLeadSeconds(logPrefix);
244
267
  const scheduler = createTokenRefreshScheduler(
245
268
  async () => {
@@ -247,7 +270,7 @@ export function startTokenRefresh(opts: {
247
270
  const headroomSeconds = 10;
248
271
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
249
272
  try {
250
- const refreshed = await refreshConversationToken(baseUrl, runtime.id, runtime.token, fingerprint);
273
+ const refreshed = await refreshConversationToken(getBaseUrl(), runtime.id, runtime.token, fingerprint);
251
274
  runtime.token = refreshed.conversation_token;
252
275
  runtime.expiresAt = refreshed.expires_at;
253
276
  process.stderr.write(`${logPrefix} token refreshed for conversation ${runtime.id}\n`);
@@ -333,6 +356,32 @@ export function decideTurnPostAction(opts: {
333
356
  return "auto-post";
334
357
  }
335
358
 
359
+ /**
360
+ * #429 cross-run dedup, mirror of dm_stream's `forwardMessage` guard (L146-147).
361
+ * True when this id was already DELIVERED in a prior run and must be skipped on a
362
+ * re-join. The D1 message id is `INTEGER PRIMARY KEY AUTOINCREMENT` (migration
363
+ * 0009) → strictly increasing, never reused, so anything at or below the watermark
364
+ * was already handled. `Number.isSafeInteger` (not `isFinite`) gate: an id beyond
365
+ * 2^53 — where `Number()` loses precision and could collide — falls through to
366
+ * normal handling instead of being mis-gated. No watermark / absent / NaN id →
367
+ * never skips. Pure + exported for unit tests (pattern `decideTurnPostAction`).
368
+ */
369
+ export function watermarkShouldSkip(numId: number, watermark?: BridgeWatermark): boolean {
370
+ return !!watermark && Number.isSafeInteger(numId) && numId <= watermark.lastId;
371
+ }
372
+
373
+ /**
374
+ * #429 advance the cross-run watermark after a SUCCESSFUL delivery (mirror of
375
+ * dm_stream L166-168). Monotone: only ever moves forward, so a brief overlap of
376
+ * two streams during supersession can't regress it. Same `Number.isSafeInteger`
377
+ * gate; a NaN/absent/unsafe id is never watermarked. Pure + exported.
378
+ */
379
+ export function watermarkAdvance(numId: number, watermark?: BridgeWatermark): void {
380
+ if (watermark && Number.isSafeInteger(numId) && numId > watermark.lastId) {
381
+ watermark.lastId = numId;
382
+ }
383
+ }
384
+
336
385
  /** Bearer + fingerprint + CF Access headers for a conversation request. */
337
386
  function conversationHeaders(conversationToken: string, fingerprint: string): Record<string, string> {
338
387
  return {
@@ -378,9 +427,25 @@ export interface ConversationStreamDeps {
378
427
  options: SseLoopOptions;
379
428
  /** Registry of live streams (keyed by conversation id) — re-join supersedes. */
380
429
  activeStreams: Map<string, AbortController>;
430
+ /**
431
+ * Registry of persistent per-conversation watermarks (#429). Keyed by
432
+ * conversation id. UNLIKE `activeStreams`, this MUST survive the abort+restart
433
+ * on re-join (never deleted in the stream's finally) — that persistence is the
434
+ * whole point of the cross-run dedup.
435
+ */
436
+ watermarks: Map<string, BridgeWatermark>;
381
437
  /** Global shutdown signal — aborting it stops every stream. */
382
438
  parentSignal: AbortSignal;
383
- baseUrl: string;
439
+ /**
440
+ * #480: the API host as STATE, not a string. Passing the resolver rather than
441
+ * a captured value is what lets a long-lived stream follow the host the server
442
+ * announces mid-run; a `string` here would freeze the bootstrap host for the
443
+ * whole run. It also removes the swap hazard a second `string` field next to
444
+ * `identityBaseUrl` would create.
445
+ */
446
+ api: ApiBaseUrlResolver;
447
+ /** Logical backend identity — what the session handle is keyed on (#480). */
448
+ identityBaseUrl: string;
384
449
  lockDir: string;
385
450
  logPrefix: string;
386
451
  /** Session-handle tag distinguishing runtimes in the handle path (codex/gemma). */
@@ -405,7 +470,9 @@ export async function runConversationStream(
405
470
  minted: ConversationMintResponse,
406
471
  deps: ConversationStreamDeps,
407
472
  ): Promise<void> {
408
- const { fingerprint, instanceKey, options, activeStreams, parentSignal, baseUrl, lockDir, logPrefix, sessionTag, makeRunTurn } = deps;
473
+ const { fingerprint, instanceKey, options, activeStreams, watermarks, parentSignal, api, identityBaseUrl, lockDir, logPrefix, sessionTag, makeRunTurn } = deps;
474
+ // #480: a FUNCTION, not a captured string — the host can change mid-stream.
475
+ const baseUrl = (): string => api.current();
409
476
  const convId = minted.conversation_id;
410
477
  if (parentSignal.aborted) return;
411
478
 
@@ -430,12 +497,14 @@ export async function runConversationStream(
430
497
  parentSignal.addEventListener("abort", onParentAbort, { once: true });
431
498
  // Per-conversation resume handle, keyed by conversation id so a secondary DM
432
499
  // never clobbers the main conversation's handle.
433
- writeHandle(sessionFilePath(baseUrl, `${instanceKey}:${sessionTag}:${convId}`, lockDir), convId);
500
+ // #480: keyed on the identity, not the transport host — a redirected agent
501
+ // must find the SAME handle and resume its conversation, not mint a new one.
502
+ writeHandle(sessionFilePath(identityBaseUrl, `${instanceKey}:${sessionTag}:${convId}`, lockDir), convId);
434
503
  // Token-refresh failure aborts ONLY this stream, not the whole bridge.
435
504
  startTokenRefresh({
436
505
  runtime,
437
506
  fingerprint,
438
- baseUrl,
507
+ getBaseUrl: baseUrl,
439
508
  requestShutdown: (err) => {
440
509
  if (!ctrl.signal.aborted) ctrl.abort(err);
441
510
  },
@@ -443,7 +512,15 @@ export async function runConversationStream(
443
512
  });
444
513
  const runTurn = await makeRunTurn(convId, runtime, ctrl.signal);
445
514
  process.stderr.write(`${logPrefix} stream ready for ${convId}\n`);
446
- await sseLoop({ minted, runtime, fingerprint, ownInstanceKey: instanceKey, options, signal: ctrl.signal, baseUrl, logPrefix, runTurn });
515
+ // Get-or-create the persistent watermark for this conversation. It lives in
516
+ // the caller-owned `watermarks` registry so it SURVIVES this stream's
517
+ // abort+restart on re-join (the finally below deliberately does NOT delete it).
518
+ let watermark = watermarks.get(convId);
519
+ if (!watermark) {
520
+ watermark = { lastId: 0 };
521
+ watermarks.set(convId, watermark);
522
+ }
523
+ await sseLoop({ minted, runtime, fingerprint, ownInstanceKey: instanceKey, options, signal: ctrl.signal, getBaseUrl: baseUrl, logPrefix, runTurn, watermark });
447
524
  } finally {
448
525
  parentSignal.removeEventListener("abort", onParentAbort);
449
526
  runtime.scheduler?.cancel();
@@ -481,11 +558,26 @@ export async function sseLoop(opts: {
481
558
  ownInstanceKey: string;
482
559
  options: SseLoopOptions;
483
560
  signal: AbortSignal;
484
- baseUrl: string;
561
+ /** #480 — the loop outlives the announcement; read the host per use. */
562
+ getBaseUrl: () => string;
485
563
  logPrefix: string;
486
564
  runTurn: RunTurn;
565
+ /**
566
+ * Persistent cross-run watermark for this conversation (#429). When present,
567
+ * a re-join skips re-replying to any message id already delivered in a prior
568
+ * run. Unused in step-01 (plumbing only); the guard/advance lands in step-02.
569
+ * `runConversationStream` always passes it; a direct test caller may omit it.
570
+ */
571
+ watermark?: BridgeWatermark;
572
+ /** Injectable for tests; defaults to global fetch (mirror dm_stream). */
573
+ fetchImpl?: typeof fetch;
574
+ /** Injectable catch-up fetch; defaults to messages.ts `fetchMessages`. */
575
+ fetchMessagesImpl?: typeof fetchMessages;
487
576
  }): Promise<void> {
488
- const { minted, runtime, fingerprint, ownInstanceKey, options, signal, baseUrl, logPrefix, runTurn } = opts;
577
+ const { minted, runtime, fingerprint, ownInstanceKey, options, signal, getBaseUrl, logPrefix, runTurn, watermark } = opts;
578
+ const baseUrl = getBaseUrl; // called at each use below
579
+ const fetchImpl = opts.fetchImpl ?? fetch;
580
+ const fetchMessagesImpl = opts.fetchMessagesImpl ?? fetchMessages;
489
581
  const sseUrl = buildSseUrl(minted.ws_url, minted.conversation_id);
490
582
  const ownPostedIds = new Set<string>();
491
583
 
@@ -522,7 +614,7 @@ export async function sseLoop(opts: {
522
614
  process.stderr.write(`${logPrefix} turn on ${runtime.id} produced no text and used no outbound tool; nothing to deliver\n`);
523
615
  return false;
524
616
  case "auto-post":
525
- await postBridgeReply({ baseUrl, runtime, text: reply, ownPostedIds, signal, logPrefix });
617
+ await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix });
526
618
  return false;
527
619
  }
528
620
  }
@@ -563,7 +655,7 @@ export async function sseLoop(opts: {
563
655
  if (!idStr) return;
564
656
  if (ackedIds.has(idStr) || ackInFlightIds.has(idStr)) return;
565
657
  ackInFlightIds.add(idStr);
566
- void ackMessage(baseUrl, runtime.id, runtime.token, idStr, signal)
658
+ void ackMessage(baseUrl(), runtime.id, runtime.token, idStr, signal, fetchImpl)
567
659
  .then((ok) => {
568
660
  if (ok) ackedIds.add(idStr);
569
661
  else process.stderr.write(`${logPrefix} ack failed for message ${idStr} (will retry on reconnect)\n`);
@@ -573,10 +665,18 @@ export async function sseLoop(opts: {
573
665
 
574
666
  async function handleInboundMessage(msg: ConversationMessage): Promise<boolean> {
575
667
  const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
668
+ // TRUTHY check (not `!== undefined`): idStr === "" must yield NaN, not
669
+ // Number("") === 0, so a blank id never satisfies the `<= lastId` guard.
670
+ const numId = idStr ? Number(idStr) : Number.NaN;
576
671
  // Confirm receipt to the web INDEPENDENTLY of handled/reply state, BEFORE the
577
672
  // handledIds early-return — so a previously-failed ACK is retried when this
578
673
  // message comes back through catch-up on reconnect (codex step-02 review).
579
674
  ackInboundReceipt(msg);
675
+ // #429 cross-run dedup: a re-join re-delivers the trigger; skip replying to
676
+ // anything already delivered in a prior run. Placed AFTER ackInboundReceipt
677
+ // so the #346 read-ACK still fires on a re-delivered trigger, only the reply
678
+ // is suppressed (mirror of dm_stream; handledIds still gates the intra-run dup).
679
+ if (watermarkShouldSkip(numId, watermark)) return false;
580
680
  if (idStr && handledIds.has(idStr)) return false;
581
681
  if (!shouldHandleMessage(msg, ownInstanceKey, ownPostedIds, options)) {
582
682
  if (idStr) handledIds.add(idStr); // skip decision is final — role won't change
@@ -590,6 +690,12 @@ export async function sseLoop(opts: {
590
690
  try {
591
691
  const aborted = await replyForTurn(msg.content!, msg);
592
692
  if (aborted) return false;
693
+ // #429 advance ONLY after a non-aborted reply (delivered — incl.
694
+ // suppress-outbound and skip-empty). INSIDE the try, after the abort
695
+ // check, never in catch/finally: a thrown reply (caught below) is a
696
+ // failed delivery → NO advance, so it retries on the next re-join
697
+ // (at-least-once, same asymmetry as dm_stream's watermark).
698
+ watermarkAdvance(numId, watermark);
593
699
  } catch (err) {
594
700
  if (err instanceof BridgeShutdownError) throw err;
595
701
  process.stderr.write(`${logPrefix} failed to handle message: ${safeErrorMessage(err)}\n`);
@@ -607,7 +713,7 @@ export async function sseLoop(opts: {
607
713
  // is the SSE/voice host (the CF tunnel in staging/prod). Using ws_url here
608
714
  // 404s whenever the SSE host differs from the API host (masked in dev where
609
715
  // they collapse to one origin). The SSE subscription still uses ws_url.
610
- msgs = await fetchMessages(baseUrl, minted.conversation_id, runtime.token);
716
+ msgs = await fetchMessagesImpl(baseUrl(), minted.conversation_id, runtime.token);
611
717
  } catch (err) {
612
718
  if (err instanceof ConversationTokenExpiredError) throw err;
613
719
  process.stderr.write(`${logPrefix} catch-up fetch failed: ${safeErrorMessage(err)}\n`);
@@ -660,7 +766,7 @@ export async function sseLoop(opts: {
660
766
  requestHeaders.Accept = "text/event-stream";
661
767
  process.stderr.write(`${logPrefix} connecting to SSE: ${sseUrl}\n`);
662
768
 
663
- const resp = await fetch(sseUrl, { headers: requestHeaders, signal });
769
+ const resp = await fetchImpl(sseUrl, { headers: requestHeaders, signal });
664
770
  if (resp.status === 401) throw new ConversationTokenExpiredError();
665
771
  if (!resp.ok || !resp.body) throw new Error(`SSE connection failed: HTTP ${resp.status}`);
666
772
 
@@ -774,9 +880,69 @@ export function sanitizeAgentRole(raw: string | undefined): string {
774
880
  return code < 0x20 || code === 0x7f ? " " : ch;
775
881
  })
776
882
  .join("");
777
- const stripped = noCtrl.replace(/\s+/g, " ").trim();
778
- if (stripped.length <= MAX_ROLE_LENGTH) return stripped;
779
- return stripped.slice(0, MAX_ROLE_LENGTH).trimEnd();
883
+ const stripped = normalizeEscapables(noCtrl.replace(/\s+/g, " ").trim());
884
+ return capUtf16Safe(stripped, MAX_ROLE_LENGTH);
885
+ }
886
+
887
+ /**
888
+ * Normalize the two characters `JSON.stringify` expands MOST in practice (#479): a quote
889
+ * becomes an apostrophe, a backslash a forward slash. Both stay readable —
890
+ * `C:\Users` → `C:/Users`, `the "taskman" skill` → `the 'taskman' skill` — and
891
+ * neither `'` nor `/` is escaped, so a 600-char role can no longer encode to 1200
892
+ * and blow the instructions budget. Removing them outright would corrupt paths and
893
+ * citations; rewriting them keeps the meaning and bounds the length.
894
+ *
895
+ * They are not the ONLY expanding inputs: control characters (already turned into
896
+ * spaces by the callers) and a LONE SURROGATE (escaped as `\udXXX` — six chars for
897
+ * one code unit) also grow. Unpaired surrogates are therefore dropped here, and
898
+ * `capUtf16Safe` below makes sure the length cap never creates a new one. Between
899
+ * the two, no input can make the encoded text longer than the raw text.
900
+ */
901
+ function normalizeEscapables(text: string): string {
902
+ return dropLoneSurrogates(text).replaceAll('"', "'").replaceAll("\\", "/");
903
+ }
904
+
905
+ /**
906
+ * Drop UNPAIRED surrogates (#479 opus review). `capUtf16Safe` stops the cap from
907
+ * CREATING one, but an unpaired unit already present in the input would sail
908
+ * through and encode to `\udXXX` — six chars for one unit, so a 600-char role could
909
+ * still reach ~3600 encoded. Dropping them closes the last expansion path, which is
910
+ * what makes IDENTITY_BUDGET exact for ANY input and the clip a true never-path.
911
+ * A lone surrogate carries no text anyway: it is a broken half-character.
912
+ */
913
+ function dropLoneSurrogates(text: string): string {
914
+ let out = "";
915
+ for (let i = 0; i < text.length; i++) {
916
+ const code = text.charCodeAt(i);
917
+ const isHigh = code >= 0xd800 && code <= 0xdbff;
918
+ const isLow = code >= 0xdc00 && code <= 0xdfff;
919
+ if (isHigh) {
920
+ const next = text.charCodeAt(i + 1);
921
+ if (next >= 0xdc00 && next <= 0xdfff) {
922
+ out += text[i] + text[i + 1];
923
+ i++;
924
+ }
925
+ continue; // unpaired high → dropped
926
+ }
927
+ if (isLow) continue; // unpaired low → dropped
928
+ out += text[i];
929
+ }
930
+ return out;
931
+ }
932
+
933
+ /**
934
+ * Cap `text` to `max` UTF-16 units WITHOUT leaving a lone surrogate behind
935
+ * (codex-479 review): a plain `slice` can cut an emoji in half, and a lone
936
+ * surrogate is itself escaped by `JSON.stringify` (`\udXXX`) — the very expansion
937
+ * the normalization above exists to prevent, plus an unpaired code unit in the
938
+ * prompt. The cap stays in UTF-16 units so `IDENTITY_BUDGET` remains exact.
939
+ */
940
+ function capUtf16Safe(text: string, max: number): string {
941
+ if (text.length <= max) return text;
942
+ const cut = text.slice(0, max);
943
+ const last = cut.charCodeAt(cut.length - 1);
944
+ const orphanHighSurrogate = last >= 0xd800 && last <= 0xdbff;
945
+ return (orphanHighSurrogate ? cut.slice(0, -1) : cut).trimEnd();
780
946
  }
781
947
 
782
948
  /** Max explicit-name length after sanitization (chars). */
@@ -797,8 +963,8 @@ export function sanitizeAgentName(raw: string | undefined): string {
797
963
  return code < 0x20 || code === 0x7f ? " " : ch;
798
964
  })
799
965
  .join("");
800
- const stripped = noCtrl.replace(/\s+/g, " ").trim();
801
- return stripped.length <= MAX_NAME_LENGTH ? stripped : stripped.slice(0, MAX_NAME_LENGTH).trimEnd();
966
+ const stripped = normalizeEscapables(noCtrl.replace(/\s+/g, " ").trim());
967
+ return capUtf16Safe(stripped, MAX_NAME_LENGTH);
802
968
  }
803
969
 
804
970
  /**
@@ -824,6 +990,13 @@ export function sanitizeAgentName(raw: string | undefined): string {
824
990
  export function buildAgentIdentityInstructions(opts: {
825
991
  explicitName?: string;
826
992
  role?: string;
993
+ /**
994
+ * Hard ceiling (chars) for the RENDERED line (#479). The MCP client truncates
995
+ * long `instructions`, so the identity line must not eat the channel-contract
996
+ * budget: the caller reserves a slice and we fit into it OURSELVES rather than
997
+ * letting the client cut wherever it lands. Omitted = no ceiling.
998
+ */
999
+ budget?: number;
827
1000
  }): string {
828
1001
  const name = sanitizeAgentName(opts.explicitName);
829
1002
  const role = sanitizeAgentRole(opts.role);
@@ -835,8 +1008,57 @@ export function buildAgentIdentityInstructions(opts: {
835
1008
  const nameClause = name
836
1009
  ? ` Your explicit spawn name is ${JSON.stringify(name)}; if asked your name, answer with it.`
837
1010
  : " You do not have an explicit agent name, so do not invent one if asked.";
838
- const roleClause = role ? ` Your assigned role is ${JSON.stringify(role)}.` : "";
839
- return `${guard}${nameClause}${roleClause}`;
1011
+
1012
+ // The guard and the name are NEVER trimmed (#328: an agent that lost its name
1013
+ // is the bug this line exists to prevent; a half-quoted guard would weaken the
1014
+ // "descriptive DATA" framing). Only the role clause absorbs the budget.
1015
+ const fixed = `${guard}${nameClause}`;
1016
+ const roleClause = role ? renderRoleClause(role, budgetForRole(opts.budget, fixed.length)) : "";
1017
+ return `${fixed}${roleClause}`;
1018
+ }
1019
+
1020
+ /** Chars left for the role clause once guard + name are reserved. */
1021
+ function budgetForRole(budget: number | undefined, fixedLength: number): number {
1022
+ if (budget === undefined) return Number.POSITIVE_INFINITY;
1023
+ return budget - fixedLength;
1024
+ }
1025
+
1026
+ /**
1027
+ * Render ` Your assigned role is "<role>".` within `available` chars, or "" when
1028
+ * even the empty envelope does not fit (#479).
1029
+ *
1030
+ * Clipping happens on the role text BEFORE serialization, by CODE POINT — never
1031
+ * on the serialized string (codex review): cutting encoded text can split an
1032
+ * escape sequence (`é`) or a surrogate pair and leave the JSON string
1033
+ * unterminated, which is exactly what would break out of the quoted-data framing.
1034
+ * So we search the longest code-point prefix whose `JSON.stringify` still fits.
1035
+ */
1036
+ function renderRoleClause(role: string, available: number): string {
1037
+ const wrap = (encoded: string): string => ` Your assigned role is ${encoded}.`;
1038
+ if (wrap(JSON.stringify("")).length > available) return "";
1039
+ const full = wrap(JSON.stringify(role));
1040
+ if (full.length <= available) return full;
1041
+
1042
+ // Binary-search the longest fitting prefix. Code points, not UTF-16 units, so a
1043
+ // non-BMP character is never cut in half.
1044
+ const points = Array.from(role);
1045
+ let lo = 0;
1046
+ let hi = points.length;
1047
+ while (lo < hi) {
1048
+ const mid = Math.ceil((lo + hi) / 2);
1049
+ if (wrap(JSON.stringify(points.slice(0, mid).join(""))).length <= available) lo = mid;
1050
+ else hi = mid - 1;
1051
+ }
1052
+ const clipped = wrap(JSON.stringify(points.slice(0, lo).join("").trimEnd()));
1053
+ // Never silent: a clip means the operator's role prompt did not fully reach the
1054
+ // agent. Lengths only — the role itself is not logged (it can be long, and the
1055
+ // log is not the place for it).
1056
+ process.stderr.write(
1057
+ `[viber-channel] identity: role clause CLIPPED to fit the instructions budget ` +
1058
+ `(${points.length} code points → ${lo}; clause ${full.length} → ${clipped.length} chars, ` +
1059
+ `available ${available}). Shorten the role prompt to avoid losing its tail.\n`,
1060
+ );
1061
+ return clipped;
840
1062
  }
841
1063
 
842
1064
  /**
@@ -848,7 +1070,7 @@ export function buildAgentIdentityInstructions(opts: {
848
1070
  * - a Claude terminal session (#328) has no bridge — its label is
849
1071
  * `VIBER_CHANNEL_LABEL` (set by buildClaudeLaunchSpec) — so fall back to it.
850
1072
  */
851
- export function agentIdentityFromEnv(): string {
1073
+ export function agentIdentityFromEnv(budget?: number): string {
852
1074
  const explicit = process.env.VIBER_AGENT_NAME_EXPLICIT === "1";
853
1075
  // A BLANK bridge label counts as absent (defense in depth, #328): the terminal
854
1076
  // launcher wipes an inherited VIBER_CODEX_BRIDGE_LABEL to "", so a Claude
@@ -859,6 +1081,10 @@ export function agentIdentityFromEnv(): string {
859
1081
  return buildAgentIdentityInstructions({
860
1082
  explicitName: explicit ? label : undefined,
861
1083
  role: process.env.VIBER_AGENT_ROLE,
1084
+ // #479: the MCP surface passes IDENTITY_BUDGET so the line cannot eat the
1085
+ // channel contract. The bridges pass nothing — their developer instructions
1086
+ // are not truncated, so a full role is safe there.
1087
+ budget,
862
1088
  });
863
1089
  }
864
1090
 
@@ -869,6 +1095,8 @@ export interface BridgeIdentity {
869
1095
  instanceKey: string;
870
1096
  instanceToken: string;
871
1097
  bridgeLock: BridgeLock;
1098
+ /** #480 — host announced at register, when this call actually registered. */
1099
+ apiBaseUrlAnnounced?: string;
872
1100
  }
873
1101
 
874
1102
  /**
@@ -909,7 +1137,10 @@ export async function acquireBridgeInstance(opts: {
909
1137
  * both await an invite. Mirrors the Codex bridge's acquireBridgeIdentity, generic.
910
1138
  */
911
1139
  export async function acquireBridgeIdentity(opts: {
1140
+ /** Transport host — what we fetch (#480). */
912
1141
  baseUrl: string;
1142
+ /** Logical backend identity — keys the bridge lock (#480). */
1143
+ identityBaseUrl: string;
913
1144
  auth: AuthJson;
914
1145
  fingerprint: string;
915
1146
  label: string;
@@ -919,17 +1150,22 @@ export async function acquireBridgeIdentity(opts: {
919
1150
  sessionTag: string;
920
1151
  reuseAuthInstance: boolean;
921
1152
  }): Promise<BridgeIdentity> {
922
- const { baseUrl, fingerprint, lockDir, logPrefix, sessionTag } = opts;
1153
+ const { identityBaseUrl, fingerprint, lockDir, logPrefix, sessionTag } = opts;
923
1154
  mkdirSync(lockDir, { recursive: true });
924
1155
  const acquired = await acquireBridgeInstance(opts);
925
1156
  const bridgeLock = acquireBridgeLock({
926
- baseUrl,
1157
+ identityBaseUrl,
927
1158
  fingerprint,
928
1159
  sessionId: `${sessionTag}-agent:${acquired.instance_key}`,
929
1160
  lockDir,
930
1161
  logPrefix,
931
1162
  });
932
- return { instanceKey: acquired.instance_key, instanceToken: acquired.instance_token, bridgeLock };
1163
+ return {
1164
+ instanceKey: acquired.instance_key,
1165
+ instanceToken: acquired.instance_token,
1166
+ bridgeLock,
1167
+ apiBaseUrlAnnounced: acquired.api_base_url,
1168
+ };
933
1169
  }
934
1170
 
935
1171
  /** Live helpers a runtime adapter uses inside onStart (e.g. the Codex tool host). */
@@ -947,7 +1183,10 @@ export interface BridgeHelpers {
947
1183
 
948
1184
  /** A runtime's adapter for the shared await-invite bridge. */
949
1185
  export interface AwaitInviteAdapter {
950
- baseUrl: string;
1186
+ /** #480 — API host as state (see ConversationStreamDeps.api). */
1187
+ api: ApiBaseUrlResolver;
1188
+ /** Logical backend identity — keys the lock + session handle (#480). */
1189
+ identityBaseUrl: string;
951
1190
  lockDir: string;
952
1191
  logPrefix: string;
953
1192
  /** Lock + session-handle tag distinguishing runtimes (codex / gemma). */
@@ -978,16 +1217,25 @@ export interface AwaitInviteAdapter {
978
1217
  * makeRunTurn). Throws BridgeShutdownError so the caller maps it to an exit code.
979
1218
  */
980
1219
  export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise<void> {
981
- const { baseUrl, lockDir, logPrefix, sessionTag, label, kind, options, auth, fingerprint, makeRunTurn, onStart, onShutdown } = adapter;
1220
+ const { api, identityBaseUrl, lockDir, logPrefix, sessionTag, label, kind, options, auth, fingerprint, makeRunTurn, onStart, onShutdown } = adapter;
1221
+ const baseUrl = (): string => api.current();
982
1222
  const shutdown = new AbortController();
983
1223
  let bridgeLock: BridgeLock | null = null;
984
1224
  let controlStream: PersistentControlStream | null = null;
985
1225
  const activeStreams = new Map<string, AbortController>();
1226
+ // Persistent per-conversation watermarks (#429). Lives for the WHOLE bridge run
1227
+ // (never cleared per-stream) so a conversation's watermark survives the
1228
+ // abort+restart on re-join. Bounded growth: one `{lastId}` per convId seen —
1229
+ // acceptable (consensus reviewers, step-01 invariant).
1230
+ const watermarks = new Map<string, BridgeWatermark>();
986
1231
  const streamTasks = new Set<Promise<void>>();
987
1232
  let voiceBaseUrl = "";
988
1233
  let instanceKey = "";
989
1234
 
990
1235
  const startConversation = (minted: ConversationMintResponse): void => {
1236
+ // A pushed join is the only announcement a bridge that reuses a persisted
1237
+ // token ever sees (no register call ⇒ nothing announced there).
1238
+ api.offer(minted.api_base_url);
991
1239
  if (!voiceBaseUrl) voiceBaseUrl = minted.ws_url;
992
1240
  process.stderr.write(`${logPrefix} join pushed for ${minted.conversation_id} — starting stream\n`);
993
1241
  // `let task!` (not const): the `.finally` closure reads `task`; with a const
@@ -998,8 +1246,10 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
998
1246
  instanceKey,
999
1247
  options,
1000
1248
  activeStreams,
1249
+ watermarks,
1001
1250
  parentSignal: shutdown.signal,
1002
- baseUrl,
1251
+ api,
1252
+ identityBaseUrl,
1003
1253
  lockDir,
1004
1254
  logPrefix,
1005
1255
  sessionTag,
@@ -1021,11 +1271,14 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1021
1271
 
1022
1272
  try {
1023
1273
  const identity = await acquireBridgeIdentity({
1024
- baseUrl, auth, fingerprint, label, kind, lockDir, logPrefix, sessionTag,
1274
+ baseUrl: baseUrl(), identityBaseUrl, auth, fingerprint, label, kind, lockDir, logPrefix, sessionTag,
1025
1275
  reuseAuthInstance: options.reuseAuthInstance ?? false,
1026
1276
  });
1027
1277
  bridgeLock = identity.bridgeLock;
1028
1278
  instanceKey = identity.instanceKey;
1279
+ // #480: adopt BEFORE the control stream and the heartbeat start — they are
1280
+ // this bridge's two longest-lived flows and the whole point of the change.
1281
+ api.offer(identity.apiBaseUrlAnnounced);
1029
1282
 
1030
1283
  const helpers: BridgeHelpers = {
1031
1284
  startConversation,
@@ -1039,7 +1292,9 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1039
1292
 
1040
1293
  process.stderr.write(`${logPrefix} await-invite: control stream open, waiting for joins (instance ${identity.instanceKey.slice(0, 8)})\n`);
1041
1294
  controlStream = runPersistentControlStream(
1042
- { baseUrl, instanceId: identity.instanceKey, instanceToken: identity.instanceToken, signal: shutdown.signal },
1295
+ // Evaluated here, i.e. AFTER api.offer() above — so the stream opens on
1296
+ // the announced host rather than the bootstrap one.
1297
+ { getBaseUrl: baseUrl, instanceId: identity.instanceKey, instanceToken: identity.instanceToken, signal: shutdown.signal },
1043
1298
  {
1044
1299
  log: (m) => process.stderr.write(m),
1045
1300
  onJoin: (minted) => startConversation(minted),
@@ -1052,12 +1307,12 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1052
1307
  );
1053
1308
  },
1054
1309
  onBeatNow: () => {
1055
- void sendInstanceHeartbeat(baseUrl, identity.instanceKey, identity.instanceToken);
1310
+ void sendInstanceHeartbeat(baseUrl(), identity.instanceKey, identity.instanceToken);
1056
1311
  },
1057
1312
  },
1058
1313
  );
1059
1314
  const ihb = startInstanceHeartbeat({
1060
- baseUrl,
1315
+ getBaseUrl: baseUrl,
1061
1316
  instanceId: identity.instanceKey,
1062
1317
  getInstanceToken: () => identity.instanceToken,
1063
1318
  });
@@ -29,6 +29,7 @@ import {
29
29
  type AgentToolsContext,
30
30
  type AgentToolResult,
31
31
  } from "./agent_tools.js";
32
+ import { CONTRACT_STYLE_NEUTRAL, CONTRACT_STYLE_TOOLS } from "./channel_instructions.js";
32
33
 
33
34
  /** The three channel tools, named so codex (and the elicitation whitelist) match. */
34
35
  export const BRIDGE_TOOL_NAMES = ["send_message", "list_agents", "message_agent"] as const;
@@ -44,17 +45,32 @@ export const TOOL_DEFS = [
44
45
  {
45
46
  name: "send_message",
46
47
  description:
47
- "Reply to the USER. This is the ONLY way to reach the user — they do NOT see your runtime/stdout, so never answer there. " +
48
- "Post your reply into the current Viber conversation. `text` is the conversational reply — " +
49
- "read aloud AND shown as Markdown, so keep it short and natural but use light Markdown (short " +
50
- "lists, numbered steps, **bold**) for readability; put actions/questions here, visibly. " +
51
- "`artifact` is optional HEAVY/LONG content (big code, large tables, long analyses, json, " +
52
- "sanitized html) shown in a side viewer; when used, keep `text` a brief summary pointing to it. " +
53
- "Calling this IS your reply for the turn.",
48
+ // #479 codex review: this used to claim send_message was "the ONLY way to
49
+ // reach the user". False on THIS surface — the bridge auto-posts a turn's
50
+ // text when no outbound tool succeeded, and the system prompt says so. Two
51
+ // contradictory statements about delivery is exactly the defect #479 fixes
52
+ // elsewhere, so the wording is non-exclusive here.
53
+ "Reply to the USER by posting into the current Viber conversation. Your runtime/stdout is NOT the user — never treat it as the reply. " +
54
+ "Calling this delivers your reply for the turn (and suppresses the bridge's auto-post fallback). " +
55
+ // #479 volet 2: the SAME contract fragments Claude gets. They used to exist
56
+ // only in the MCP `instructions` blob, which never reaches codex/gemma at all
57
+ // — so NO EMOJI and the literal-identifier rule were simply absent here.
58
+ CONTRACT_STYLE_NEUTRAL +
59
+ " " +
60
+ CONTRACT_STYLE_TOOLS,
54
61
  inputSchema: {
55
62
  type: "object",
56
63
  properties: {
57
- text: { type: "string", description: "Spoken/conversational reply. Plain sentences." },
64
+ // #479: this used to read "Plain sentences." — a leftover of the retired
65
+ // "no markdown" rule, contradicting the description right above it, which
66
+ // asks for light Markdown. The contract has ONE wording now.
67
+ text: {
68
+ type: "string",
69
+ description:
70
+ "Spoken/conversational reply: read aloud AND shown as Markdown. Short and natural, light " +
71
+ "Markdown (short lists, **bold**) for readability. Actions and questions go here, visibly. " +
72
+ "NO emoji (it is read aloud). Write identifiers LITERALLY (288, auth.json).",
73
+ },
58
74
  artifact: {
59
75
  type: "object",
60
76
  properties: {