viber-channel 0.8.17 → 0.8.20

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.
@@ -78,6 +78,16 @@ export interface AgentToolsContext {
78
78
  * signal); the Codex bridge returns its per-turn stream signal (#303 parity).
79
79
  */
80
80
  abortSignal?(): AbortSignal | undefined;
81
+ /**
82
+ * #627: why this agent is PAUSED, or null when it may write. A paused agent has no
83
+ * confirmed conversation lease, so it must not post anything; the tool says why.
84
+ */
85
+ pauseReason?(): string | null;
86
+ }
87
+
88
+ function pausedResult(ctx: AgentToolsContext): AgentToolResult | null {
89
+ const reason = ctx.pauseReason?.() ?? null;
90
+ return reason === null ? null : errorText(`Paused — ${reason}. Nothing was sent; it resumes on its own when the runner covers this agent again.`);
81
91
  }
82
92
 
83
93
  function text(s: string): AgentToolResult {
@@ -164,6 +174,8 @@ export async function messageAgent(
164
174
  if (!ctx.instanceToken() || !ctx.voiceBaseUrl()) {
165
175
  return errorText("Channel not ready: no instance identity yet.");
166
176
  }
177
+ const paused = pausedResult(ctx);
178
+ if (paused !== null) return paused;
167
179
  const peerId = args.instance_id;
168
180
  const body = args.text;
169
181
  if (typeof peerId !== "string" || peerId.trim().length === 0) {
@@ -183,7 +195,10 @@ export async function messageAgent(
183
195
  if (!outcome.ok) {
184
196
  let friendly: string;
185
197
  if (outcome.detail === "target_offline") {
186
- friendly = "That agent is offline right now — it must be running to receive a DM.";
198
+ // #627: a just-launched agent comes online with its runner's first beat (≤ 30 s).
199
+ friendly =
200
+ "That agent is offline right now — it must be running to receive a DM. " +
201
+ "If it was just launched, it comes online with its runner's next beat (within 30 s): retry then.";
187
202
  } else if (outcome.detail === "dm_unavailable_retry") {
188
203
  friendly = "Could not open the DM due to a transient conflict — try message_agent again.";
189
204
  } else {
@@ -237,6 +252,8 @@ export async function sendMessage(
237
252
  if (!ctx.sendConversationToken() || !ctx.sendConversationId()) {
238
253
  return errorText("Channel not ready: no conversation yet.");
239
254
  }
255
+ const paused = pausedResult(ctx);
256
+ if (paused !== null) return paused;
240
257
  const body = args.text;
241
258
  if (typeof body !== "string" || body.trim().length === 0) {
242
259
  return errorText("Invalid input: text must be a non-empty string");
package/lib/auth.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { chmodSync, readFileSync, writeFileSync } from "node:fs";
2
- import { isAbsolute, join } from "node:path";
2
+ import { basename, isAbsolute, join } from "node:path";
3
3
 
4
4
  export interface AuthJson {
5
5
  schema_version: number;
@@ -63,15 +63,81 @@ export function isDevBackend(
63
63
  * silently authenticated against the dev backend with STAGING creds (`-32000`)
64
64
  * because only `VIBER_BASE_URL` was set and the auth file defaulted to staging.
65
65
  */
66
- export function authFilePath(cwd: string = process.cwd()): string {
66
+ export function authFilePath(
67
+ cwd: string = process.cwd(),
68
+ baseUrl: string = process.env.VIBER_BASE_URL ?? "",
69
+ ): string {
70
+ const qa = isQaBackend(baseUrl);
67
71
  const override = process.env.VIBER_AUTH_FILE;
68
72
  if (override !== undefined && override.trim() !== "") {
73
+ assertAuthFileMatchesBackend(basename(override), qa);
69
74
  return isAbsolute(override) ? override : join(cwd, override);
70
75
  }
71
- const file = isDevBackend() ? "dev.auth.json" : "auth.json";
76
+ const file = isDevBackend(baseUrl) ? "dev.auth.json" : qa ? QA_AUTH_FILE : "auth.json";
72
77
  return join(cwd, ".viber", file);
73
78
  }
74
79
 
80
+ /**
81
+ * #613: the auth file `connect` must write for a claim URL. The CLAIM decides
82
+ * for qa: `connect https://viber-qa.dgypx.dev/connect/<id>` run with no
83
+ * VIBER_BASE_URL would otherwise derive the file from the (empty) env and write
84
+ * qa credentials into `auth.json`, overwriting STAGING's. A set env that
85
+ * contradicts the claim (qa vs not qa) is refused. Non-qa claims keep the
86
+ * pre-#613 behavior (file derived from the env) unchanged.
87
+ */
88
+ export function connectAuthPath(
89
+ claimBaseUrl: string,
90
+ cwd: string = process.cwd(),
91
+ envBaseUrl: string = process.env.VIBER_BASE_URL ?? "",
92
+ ): string {
93
+ const claimQa = isQaBackend(claimBaseUrl);
94
+ if (envBaseUrl.trim() !== "" && claimQa !== isQaBackend(envBaseUrl)) {
95
+ throw new Error(
96
+ `claim URL ${claimBaseUrl} and VIBER_BASE_URL=${envBaseUrl} disagree on the qa backend; refusing to write credentials`,
97
+ );
98
+ }
99
+ return authFilePath(cwd, claimQa ? claimBaseUrl : envBaseUrl);
100
+ }
101
+
102
+ /** #613: the qa web host. Matched EXACTLY (hostname), never by substring. */
103
+ export const QA_WEB_HOST = "viber-qa.dgypx.dev";
104
+ export const QA_AUTH_FILE = "qa.auth.json";
105
+
106
+ /**
107
+ * #613: true only when `VIBER_BASE_URL` is the qa web host. Same raw-env
108
+ * classification rule as {@link isDevBackend} (never the API transport host),
109
+ * but an exact hostname match: a substring test would let any host containing
110
+ * "qa" read qa credentials, or a qa host fall through to STAGING's auth.json.
111
+ */
112
+ export function isQaBackend(
113
+ baseUrl: string = process.env.VIBER_BASE_URL ?? "",
114
+ ): boolean {
115
+ try {
116
+ return new URL(baseUrl).hostname === QA_WEB_HOST;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * #613: a `VIBER_AUTH_FILE` override still wins, but a CONTRADICTORY one is
124
+ * refused instead of resolved in silence: qa credentials against another
125
+ * backend, or another env's well-known file against the qa backend. Custom
126
+ * file names stay allowed on both sides.
127
+ */
128
+ function assertAuthFileMatchesBackend(file: string, qa: boolean): void {
129
+ if (!qa && file === QA_AUTH_FILE) {
130
+ throw new Error(
131
+ `VIBER_AUTH_FILE points at ${QA_AUTH_FILE} but VIBER_BASE_URL is not https://${QA_WEB_HOST}`,
132
+ );
133
+ }
134
+ if (qa && (file === "auth.json" || file === "dev.auth.json")) {
135
+ throw new Error(
136
+ `VIBER_BASE_URL is the qa backend but VIBER_AUTH_FILE points at ${file} (another env's credentials)`,
137
+ );
138
+ }
139
+ }
140
+
75
141
  export function loadAuth(cwd: string = process.cwd()): AuthJson {
76
142
  const path = authFilePath(cwd);
77
143
  let raw: string;
@@ -32,12 +32,12 @@ import type { AuthJson } from "./auth.js";
32
32
  import { acquireInstance, registerInstance, instanceKindFromEnv, maybeAttachTeam, type AcquiredInstance } from "./instance.js";
33
33
  import {
34
34
  runPersistentControlStream,
35
- sendInstanceHeartbeat,
36
35
  ControlStreamStopped,
37
36
  ControlStreamAuthError,
38
37
  type PersistentControlStream,
39
38
  } from "./control_stream.js";
40
- import { startInstanceHeartbeat } from "./heartbeat.js";
39
+ import { type PresenceGate, startCoverageWatch } from "./coverage_watch.js";
40
+ import { type PresenceEndReason, type PresenceRecordHandle, startPresenceRecord } from "./presence_record.js";
41
41
 
42
42
  /** A conversation message as it arrives over the conversation SSE / message list. */
43
43
  export interface ConversationMessage {
@@ -71,11 +71,19 @@ export interface ConversationRuntime {
71
71
  scheduler?: TokenRefreshScheduler;
72
72
  }
73
73
 
74
- /** A controlled bridge exit carrying the process exit code to propagate. */
74
+ /**
75
+ * A controlled bridge exit carrying the process exit code to propagate.
76
+ *
77
+ * `endReason` (#627) names the cause for the presence record. It is set ONLY where
78
+ * the cause is known (revocation, lease lost, --once done). The class alone says
79
+ * nothing about intent: it also carries failures (token expired, refresh failed),
80
+ * which must leave the agent relaunchable.
81
+ */
75
82
  export class BridgeShutdownError extends Error {
76
83
  constructor(
77
84
  message: string,
78
85
  public readonly exitCode: number,
86
+ public readonly endReason: PresenceEndReason = "shutdown",
79
87
  ) {
80
88
  super(message);
81
89
  this.name = "BridgeShutdownError";
@@ -306,11 +314,17 @@ export async function postBridgeReply(opts: {
306
314
  ownPostedIds: Set<string>;
307
315
  signal?: AbortSignal;
308
316
  logPrefix: string;
317
+ /** Fetch seam (#606) — see postMessage. Defaults to the global fetch. */
318
+ fetchImpl?: typeof fetch;
319
+ /** #627: a reply finished during a pause waits for the resume before it is posted. */
320
+ gate?: PresenceGate;
309
321
  }): Promise<void> {
310
322
  const { baseUrl, runtime, text, ownPostedIds, signal, logPrefix } = opts;
323
+ await opts.gate?.waitActive(signal);
324
+ if (signal?.aborted) return;
311
325
  let result;
312
326
  try {
313
- result = await postMessage(baseUrl, runtime.id, runtime.token, text, undefined, signal);
327
+ result = await postMessage(baseUrl, runtime.id, runtime.token, text, undefined, signal, opts.fetchImpl ?? fetch);
314
328
  } catch (err) {
315
329
  if (err instanceof ConversationTokenExpiredError) {
316
330
  throw new BridgeShutdownError("conversation token expired", 3);
@@ -345,6 +359,93 @@ export function decideTurnPostAction(opts: {
345
359
  return "auto-post";
346
360
  }
347
361
 
362
+ /** Max length of the runtime cause quoted verbatim in a channel failure notice. */
363
+ const FAILURE_CAUSE_MAX = 400;
364
+
365
+ /** Transport degradation observed during a turn (#606 step-02, codex only). */
366
+ export type TurnDegradation = { events: number; lastStatus?: number };
367
+
368
+ /**
369
+ * #606 — the text a bridge posts on the CHANNEL when a turn fails.
370
+ *
371
+ * The whole point of the issue is that a failed turn was written to stderr only,
372
+ * so the model filled the silence with an INVENTED cause. This text therefore
373
+ * quotes what the runtime said, VERBATIM and truncated, and adds nothing of its
374
+ * own: no classification, no advice, no guess. Pure (no clock, no network) so it
375
+ * is testable without a harness.
376
+ */
377
+ export function failureNoticeText(err: unknown, degradation?: TurnDegradation): string {
378
+ const raw = safeErrorMessage(err).replace(/\s+/g, " ").trim();
379
+ const cause = raw.length > FAILURE_CAUSE_MAX ? `${raw.slice(0, FAILURE_CAUSE_MAX)}...` : raw || "(aucun detail)";
380
+ let text = `Tour echoue, aucune reponse produite. Cause rapportee par le runtime: ${cause}`;
381
+ if (degradation && degradation.events > 0) {
382
+ const status = degradation.lastStatus ? `, dernier statut HTTP ${degradation.lastStatus}` : "";
383
+ text += ` (transport degrade: ${degradation.events} evenement(s) avant l'echec${status})`;
384
+ }
385
+ return text;
386
+ }
387
+
388
+ /**
389
+ * #606 — the dedup signature of a failure cause.
390
+ *
391
+ * A blown quota fails EVERY subsequent turn: without dedup the fix that makes a
392
+ * failure speak would flood the channel. Two failures of the same cause must
393
+ * therefore yield the SAME key, so the volatile identifiers a runtime stamps on
394
+ * each attempt (turnId, threadId, cf-ray, timestamps, long hex ids) are stripped.
395
+ *
396
+ * The degradation COUNT is deliberately reduced to its presence, never its value:
397
+ * keying on the number would make every different N a "new cause" and re-open the
398
+ * flood the dedup exists to close.
399
+ */
400
+ export function failureCauseKey(err: unknown, degradation?: TurnDegradation): string {
401
+ const normalized = safeErrorMessage(err)
402
+ .replace(/"(?:turnId|threadId|conversationId|requestId)"\s*:\s*"[^"]*"/g, "")
403
+ // STOPS at a JSON delimiter (review codex P2a). `\S+` here crossed the
404
+ // closing quote and ate everything up to the next SPACE — measured: two
405
+ // errors differing only by httpStatusCode 503 vs 429 collapsed onto
406
+ // `Codex turn failed: {"message":"Request failed,` because the status sat
407
+ // AFTER the cf-ray. A normalisation that eats the discriminator gags the
408
+ // second outage: the exact defect this key exists to avoid.
409
+ .replace(/cf-ray:\s*[^\s",}\]]+/gi, "")
410
+ .replace(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/g, "")
411
+ // The RESET INSTANT of a quota, in the two shapes our own log carries for
412
+ // the same outage (review codex, .raw l.95 vs l.111): with the date and
413
+ // without. Stripping only the clock left them on different keys, so the
414
+ // same cause announced twice.
415
+ .replace(
416
+ /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2}(?:st|nd|rd|th)?,?\s*\d{4}\b/gi,
417
+ "",
418
+ )
419
+ .replace(/\b\d{1,2}:\d{2}(:\d{2})?\s*(AM|PM)?\b/gi, "")
420
+ .replace(/\b[0-9a-f]{12,}\b/gi, "")
421
+ .replace(/\s+/g, " ")
422
+ .trim();
423
+ const degraded = degradation && degradation.events > 0 ? "|degraded" : "";
424
+ // WHOLE, neither truncated nor hashed (review codex, both passes). Slicing to a
425
+ // display bound made the key lose identity past that bound — two errors sharing
426
+ // a long prefix became one cause and the second was silenced. A digest fixed
427
+ // that but kept a residual collision, which costs a SUPPRESSED notice: the
428
+ // defect this key exists to prevent, made rare instead of impossible.
429
+ // There is no size constraint to trade against: the key is one closure variable
430
+ // holding one cause at a time. Keeping the normalised string entire removes the
431
+ // class rather than shrinking it — a display concern must not decide identity,
432
+ // which is the whole lesson of P2b.
433
+ return `${normalized}${degraded}`;
434
+ }
435
+
436
+ /**
437
+ * #606 step-02 — read the transport degradation a runtime attached to the error
438
+ * it threw. The failing turn never returns a TurnResult, so the count cannot ride
439
+ * on one; the codex bridge stamps it on the Error itself. Any runtime that stamps
440
+ * nothing (gemma: a single fetch, no intermediate transport events to observe)
441
+ * simply yields undefined, and the notice omits the clause.
442
+ */
443
+ export function readTurnDegradation(err: unknown): TurnDegradation | undefined {
444
+ const d = (err as { degradation?: unknown })?.degradation as TurnDegradation | undefined;
445
+ if (!d || typeof d.events !== "number" || d.events <= 0) return undefined;
446
+ return { events: d.events, lastStatus: typeof d.lastStatus === "number" ? d.lastStatus : undefined };
447
+ }
448
+
348
449
  /**
349
450
  * #429 cross-run dedup, mirror of dm_stream's `forwardMessage` guard (L146-147).
350
451
  * True when this id was already DELIVERED in a prior run and must be skipped on a
@@ -440,6 +541,8 @@ export interface ConversationStreamDeps {
440
541
  /** Session-handle tag distinguishing runtimes in the handle path (codex/gemma). */
441
542
  sessionTag: string;
442
543
  makeRunTurn: MakeRunTurn;
544
+ /** #627: the pause gate — inbound turns and replies wait while the agent is paused. */
545
+ gate?: PresenceGate;
443
546
  }
444
547
 
445
548
  /**
@@ -509,7 +612,7 @@ export async function runConversationStream(
509
612
  watermark = { lastId: 0 };
510
613
  watermarks.set(convId, watermark);
511
614
  }
512
- await sseLoop({ minted, runtime, fingerprint, ownInstanceKey: instanceKey, options, signal: ctrl.signal, getBaseUrl: baseUrl, logPrefix, runTurn, watermark });
615
+ await sseLoop({ minted, runtime, fingerprint, ownInstanceKey: instanceKey, options, signal: ctrl.signal, getBaseUrl: baseUrl, logPrefix, runTurn, watermark, gate: deps.gate });
513
616
  } finally {
514
617
  parentSignal.removeEventListener("abort", onParentAbort);
515
618
  runtime.scheduler?.cancel();
@@ -562,14 +665,45 @@ export async function sseLoop(opts: {
562
665
  fetchImpl?: typeof fetch;
563
666
  /** Injectable catch-up fetch; defaults to messages.ts `fetchMessages`. */
564
667
  fetchMessagesImpl?: typeof fetchMessages;
668
+ /** #627: the pause gate (see ConversationStreamDeps.gate). */
669
+ gate?: PresenceGate;
565
670
  }): Promise<void> {
566
671
  const { minted, runtime, fingerprint, ownInstanceKey, options, signal, getBaseUrl, logPrefix, runTurn, watermark } = opts;
672
+ const deps = { gate: opts.gate };
567
673
  const baseUrl = getBaseUrl; // called at each use below
568
674
  const fetchImpl = opts.fetchImpl ?? fetch;
569
675
  const fetchMessagesImpl = opts.fetchMessagesImpl ?? fetchMessages;
570
676
  const sseUrl = buildSseUrl(minted.ws_url, minted.conversation_id);
571
677
  const ownPostedIds = new Set<string>();
572
678
 
679
+ // #606 anti-flood state: the cause key of the LAST failure actually announced
680
+ // on the channel. A blown quota fails every subsequent turn, so announcing each
681
+ // one would replace a silence bug with a spam bug. Closure state, owned by this
682
+ // loop — no module singleton (.claude/rules/architecture.md §7).
683
+ // Cross-RUN note. This comment CLAIMED the key survives a re-join; it does not,
684
+ // and N.11 measures the opposite (review codex, prose-has-no-test: the code was
685
+ // right, the sentence was false). What actually happens: the watermark
686
+ // deliberately does NOT advance on a failed reply (at-least-once retry, see
687
+ // below), so a re-join replays the message and fails again — and since each
688
+ // sseLoop call owns a FRESH closure, that re-join announces once more. Same for
689
+ // a process restart. Deliberate: a bridge that comes back and hits the same wall
690
+ // is information. Within one join, the key is what stops the flood.
691
+ let lastAnnouncedCause: string | undefined;
692
+
693
+ /**
694
+ * #606 — a DELIVERED turn means the previous cause is over: rearm the notice.
695
+ *
696
+ * Lives HERE, on the turn outcome, and not at a caller. Review F2 measured why:
697
+ * written at the text caller only, the VOICE path never rearmed, so
698
+ * text-fails → voice-recovers → text-fails-again left the third failure GAGGED
699
+ * — the issue's own defect, reproduced by the issue's fix. Placing it on the
700
+ * outcome removes the class instead of patching the two paths that exist today.
701
+ * An ABORTED turn never gets here: teardown says nothing about runtime health.
702
+ */
703
+ function rearmFailureNotice(): void {
704
+ lastAnnouncedCause = undefined;
705
+ }
706
+
573
707
  // Run one turn for `content` via the injected runtime, then auto-post its
574
708
  // reply UNLESS an outbound tool already posted this turn (anti-double-post).
575
709
  // Returns true when the reply was dropped because the stream was aborted
@@ -598,12 +732,15 @@ export async function sseLoop(opts: {
598
732
  return true;
599
733
  case "suppress-outbound":
600
734
  process.stderr.write(`${logPrefix} outbound tool posted this turn on ${runtime.id}; suppressing final auto-post\n`);
735
+ rearmFailureNotice();
601
736
  return false;
602
737
  case "skip-empty":
603
738
  process.stderr.write(`${logPrefix} turn on ${runtime.id} produced no text and used no outbound tool; nothing to deliver\n`);
739
+ rearmFailureNotice();
604
740
  return false;
605
741
  case "auto-post":
606
- await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix });
742
+ await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix, fetchImpl, gate: deps.gate });
743
+ rearmFailureNotice();
607
744
  return false;
608
745
  }
609
746
  }
@@ -652,6 +789,50 @@ export async function sseLoop(opts: {
652
789
  .finally(() => ackInFlightIds.delete(idStr));
653
790
  }
654
791
 
792
+
793
+ /**
794
+ * #606 — say a failed turn on the CHANNEL, best-effort.
795
+ *
796
+ * Deliberately unable to make things worse:
797
+ * - the transport may be the very thing that broke (the measured case was a
798
+ * 503 storm), so a failing notice is swallowed to stderr, NEVER retried and
799
+ * never rethrown — one failure must not become two;
800
+ * - it posts through postBridgeReply, so the notice id lands in ownPostedIds
801
+ * like any other reply and cannot feed itself back through handleInboundMessage;
802
+ * - the cause key is recorded only AFTER a post that actually succeeded, so a
803
+ * lost notice does not silence the next one.
804
+ */
805
+ async function announceTurnFailure(err: unknown): Promise<void> {
806
+ const degradation = readTurnDegradation(err);
807
+ const key = failureCauseKey(err, degradation);
808
+ if (key === lastAnnouncedCause) {
809
+ process.stderr.write(`${logPrefix} failure notice suppressed (same cause as the previous one)\n`);
810
+ return;
811
+ }
812
+ try {
813
+ await postBridgeReply({
814
+ baseUrl: baseUrl(),
815
+ runtime,
816
+ text: failureNoticeText(err, degradation),
817
+ ownPostedIds,
818
+ signal,
819
+ logPrefix,
820
+ fetchImpl,
821
+ gate: deps.gate,
822
+ });
823
+ lastAnnouncedCause = key;
824
+ } catch (notifyErr) {
825
+ // Includes BridgeShutdownError from an expired token: swallowed here on
826
+ // purpose. CONSEQUENCE, named because it is not obvious (review F3): a
827
+ // token expiry first met BY THE NOTICE is discovered later, on the next
828
+ // turn, instead of shutting the bridge down now. Accepted — this path
829
+ // runs while ALREADY handling a failure; escalating a
830
+ // shutdown from the notice would turn "the agent could not answer" into
831
+ // "the agent died", which is a worse report than the one we came to give.
832
+ process.stderr.write(`${logPrefix} failure notice could not be posted: ${safeErrorMessage(notifyErr)}\n`);
833
+ }
834
+ }
835
+
655
836
  async function handleInboundMessage(msg: ConversationMessage): Promise<boolean> {
656
837
  const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
657
838
  // TRUTHY check (not `!== undefined`): idStr === "" must yield NaN, not
@@ -674,6 +855,15 @@ export async function sseLoop(opts: {
674
855
  // AT-MOST-ONCE: mark handled BEFORE the reply so a runtime failure does not
675
856
  // re-feed the same message on reconnect (no reply storm). A failed reply is
676
857
  // dropped for this run — deliberately asymmetric with dm_stream's conduit.
858
+ // #627: while paused, the message is DEFERRED (never dropped): the turn starts
859
+ // once the runner covers this agent again. The wait comes BEFORE the message is
860
+ // marked handled: a stream stopped during the pause leaves it unhandled, and the
861
+ // persistent watermark un-advanced, so the re-join replays it (Codex, step-06).
862
+ if (deps.gate?.isPaused()) {
863
+ process.stderr.write(`${logPrefix} paused (${deps.gate.reason()}): message deferred\n`);
864
+ await deps.gate.waitActive(signal);
865
+ if (signal.aborted) return false;
866
+ }
677
867
  if (idStr) handledIds.add(idStr);
678
868
  process.stderr.write(`${logPrefix} message received: ${msg.content!.slice(0, 80)}\n`);
679
869
  try {
@@ -688,6 +878,7 @@ export async function sseLoop(opts: {
688
878
  } catch (err) {
689
879
  if (err instanceof BridgeShutdownError) throw err;
690
880
  process.stderr.write(`${logPrefix} failed to handle message: ${safeErrorMessage(err)}\n`);
881
+ await announceTurnFailure(err);
691
882
  }
692
883
  return options.once === true;
693
884
  }
@@ -792,7 +983,8 @@ export async function sseLoop(opts: {
792
983
 
793
984
  if (eventType === "stop") {
794
985
  process.stderr.write(`${logPrefix} received stop signal, exiting\n`);
795
- throw new BridgeShutdownError("received stop signal", 0);
986
+ // #627: the same server stop as claude's `stop` event → the same cause.
987
+ throw new BridgeShutdownError("received stop signal", 0, "revoked");
796
988
  }
797
989
 
798
990
  if (eventType === "transcription" && data) {
@@ -810,6 +1002,12 @@ export async function sseLoop(opts: {
810
1002
  } catch (err) {
811
1003
  if (err instanceof BridgeShutdownError) throw err;
812
1004
  process.stderr.write(`${logPrefix} failed to handle transcription: ${safeErrorMessage(err)}\n`);
1005
+ // #606: a VOICE turn that fails was the second silent swallow in this
1006
+ // same chain — found while building the tests, not by the grep on
1007
+ // "failed to handle message", which only ever proved the uniqueness of
1008
+ // the TEXT path. A user speaking to a dead runtime deserves the same
1009
+ // notice as one typing to it.
1010
+ await announceTurnFailure(err);
813
1011
  }
814
1012
  if (options.once) return;
815
1013
  }
@@ -1174,6 +1372,8 @@ export interface BridgeHelpers {
1174
1372
  parentSignal: AbortSignal;
1175
1373
  /** Request a controlled bridge shutdown (e.g. codex's app-server died). */
1176
1374
  requestShutdown: (err: BridgeShutdownError) => void;
1375
+ /** #627: why the agent is paused (null = may write) — for the tool host. */
1376
+ pauseReason: () => string | null;
1177
1377
  }
1178
1378
 
1179
1379
  /** A runtime's adapter for the shared await-invite bridge. */
@@ -1226,6 +1426,10 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1226
1426
  const streamTasks = new Set<Promise<void>>();
1227
1427
  let voiceBaseUrl = "";
1228
1428
  let instanceKey = "";
1429
+ // #627: the local presence record read by the machine's runner.
1430
+ let presence: PresenceRecordHandle | undefined;
1431
+ // #627: the pause gate, fed by the runner's local coverage (no network heartbeat).
1432
+ let gate: PresenceGate | undefined;
1229
1433
 
1230
1434
  const startConversation = (minted: ConversationMintResponse): void => {
1231
1435
  // A pushed join is the only announcement a bridge that reuses a persisted
@@ -1249,11 +1453,12 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1249
1453
  logPrefix,
1250
1454
  sessionTag,
1251
1455
  makeRunTurn,
1456
+ gate,
1252
1457
  })
1253
1458
  .then(() => {
1254
1459
  // --once: one handled message completes the bridge cleanly.
1255
1460
  if (options.once && !shutdown.signal.aborted) {
1256
- shutdown.abort(new BridgeShutdownError("once: completed", 0));
1461
+ shutdown.abort(new BridgeShutdownError("once: completed", 0, "once-completed"));
1257
1462
  }
1258
1463
  })
1259
1464
  .catch((err) => {
@@ -1271,6 +1476,21 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1271
1476
  });
1272
1477
  bridgeLock = identity.bridgeLock;
1273
1478
  instanceKey = identity.instanceKey;
1479
+ // Await-invite bridges may hold several conversations; the beat sends none,
1480
+ // so the record carries none either.
1481
+ presence = startPresenceRecord({ identityBaseUrl, instanceId: identity.instanceKey, label, projectId: auth.project_id, getConversationId: () => null, lockDir });
1482
+ // #627: no network heartbeat — the runner vouches for this bridge. An await-invite
1483
+ // bridge follows no single conversation lease, only its runner's freshness.
1484
+ const watch = startCoverageWatch({
1485
+ identityBaseUrl,
1486
+ instanceId: identity.instanceKey,
1487
+ getConversationId: () => null,
1488
+ onLeaseLost: () => shutdown.abort(new BridgeShutdownError("conversation lease lost (taken over)", 0, "lease-lost")),
1489
+ lockDir,
1490
+ log: (m) => process.stderr.write(`${logPrefix} ${m}`),
1491
+ });
1492
+ gate = watch.gate;
1493
+ shutdown.signal.addEventListener("abort", () => watch.stop(), { once: true });
1274
1494
  // #480: adopt BEFORE the control stream and the heartbeat start — they are
1275
1495
  // this bridge's two longest-lived flows and the whole point of the change.
1276
1496
  api.offer(identity.apiBaseUrlAnnounced);
@@ -1282,6 +1502,7 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1282
1502
  requestShutdown: (err) => {
1283
1503
  if (!shutdown.signal.aborted) shutdown.abort(err);
1284
1504
  },
1505
+ pauseReason: () => gate?.reason() ?? null,
1285
1506
  };
1286
1507
  await onStart?.({ identity: { instanceKey: identity.instanceKey, instanceToken: identity.instanceToken }, helpers });
1287
1508
 
@@ -1298,25 +1519,21 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1298
1519
  shutdown.abort(
1299
1520
  reason === "unauthorized"
1300
1521
  ? new BridgeShutdownError("instance token revoked or invalid", 3)
1301
- : new BridgeShutdownError("instance revoked", 0),
1522
+ : new BridgeShutdownError("instance revoked", 0, "revoked"),
1302
1523
  );
1303
1524
  },
1304
- onBeatNow: () => {
1305
- void sendInstanceHeartbeat(baseUrl(), identity.instanceKey, identity.instanceToken);
1306
- },
1525
+ // #627: an old server may still probe; this agent never beats on the network —
1526
+ // its runner answers for it (Python no longer probes a runner-covered agent).
1527
+ onBeatNow: () => {},
1307
1528
  },
1308
1529
  );
1309
- const ihb = startInstanceHeartbeat({
1310
- getBaseUrl: baseUrl,
1311
- instanceId: identity.instanceKey,
1312
- getInstanceToken: () => identity.instanceToken,
1313
- });
1314
- shutdown.signal.addEventListener("abort", () => ihb.stop(), { once: true });
1315
1530
  controlStream.firstJoin.catch(() => {});
1316
1531
  await controlStream.done;
1317
1532
  const reason = shutdown.signal.reason;
1318
1533
  if (reason instanceof BridgeShutdownError) throw reason;
1319
1534
  } finally {
1535
+ // Read BEFORE the bare abort below replaces an unset reason.
1536
+ endBridgePresence(presence, shutdown.signal.aborted ? shutdown.signal.reason : undefined);
1320
1537
  if (!shutdown.signal.aborted) shutdown.abort();
1321
1538
  const pending = [...streamTasks];
1322
1539
  for (const ctrl of activeStreams.values()) ctrl.abort(new BridgeShutdownError("bridge shutdown", 0));
@@ -1329,3 +1546,15 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1329
1546
 
1330
1547
  // Re-export control-stream error types so runtimes can map them if needed.
1331
1548
  export { ControlStreamStopped, ControlStreamAuthError };
1549
+
1550
+ /**
1551
+ * #627 — record how a bridge ended. A `BridgeShutdownError` is an end the bridge
1552
+ * went through → `ended` with its `endReason` (failures default to "shutdown").
1553
+ * Anything else — an unexpected throw — leaves the record active, like a crash.
1554
+ * The runner, not this function, decides what either means.
1555
+ */
1556
+ export function endBridgePresence(presence: PresenceRecordHandle | undefined, reason: unknown): void {
1557
+ if (presence === undefined) return;
1558
+ if (reason instanceof BridgeShutdownError) presence.markEnded(reason.endReason);
1559
+ else presence.stop();
1560
+ }
package/lib/connect.ts CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  writeFileSync,
20
20
  } from "node:fs";
21
21
  import { dirname, isAbsolute, join, relative } from "node:path";
22
- import { authFilePath } from "./auth.js";
22
+ import { connectAuthPath, isQaBackend } from "./auth.js";
23
23
  import { clientFingerprint } from "./fingerprint.js";
24
24
  import { cfAccessHeaders } from "./cfAccess.js";
25
25
 
@@ -50,6 +50,21 @@ interface TerminalResponse {
50
50
 
51
51
  type PollResponse = ConsumedResponse | PendingResponse | TerminalResponse;
52
52
 
53
+ const DEFAULT_NEXT_STEPS =
54
+ `Next steps:\n` +
55
+ ` 1. Register the channel MCP server (one-time per machine):\n` +
56
+ ` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +
57
+ ` 2. Restart Claude Code in this directory.\n\n`;
58
+
59
+ // #613: qa never runs the published channel — the default advice would be wrong
60
+ // at the exact moment of the qa gesture.
61
+ export const QA_NEXT_STEPS =
62
+ `Next steps (qa):\n` +
63
+ ` 1. Run the channel FROM SOURCE in the Gateway worktree: register viber-qa-channel\n` +
64
+ ` in its local .mcp.json (bun ./viber-channel/viber-channel.ts,\n` +
65
+ ` VIBER_BASE_URL=https://viber-qa.dgypx.dev). NEVER bunx viber-channel@latest.\n` +
66
+ ` 2. See plans/613-env-qa (mount procedure).\n\n`;
67
+
53
68
  const POLL_INTERVAL_MS = 2000;
54
69
  const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
55
70
 
@@ -77,6 +92,7 @@ function writeAuthJson(
77
92
  cwd: string,
78
93
  data: ConsumedResponse,
79
94
  fingerprint: string,
95
+ authPath: string,
80
96
  ): void {
81
97
  const viberDir = join(cwd, ".viber");
82
98
  mkdirSync(viberDir, { recursive: true });
@@ -85,7 +101,7 @@ function writeAuthJson(
85
101
  // credentials to the same file the channel will read (e.g. dev.auth.json),
86
102
  // instead of silently writing auth.json while the channel reads elsewhere.
87
103
  // readme.md + .gitignore stay in <cwd>/.viber regardless.
88
- const authPath = authFilePath(cwd);
104
+ // #613: `authPath` comes from connectAuthPath (the claim decides for qa).
89
105
  mkdirSync(dirname(authPath), { recursive: true });
90
106
 
91
107
  const auth: Record<string, unknown> = {
@@ -159,6 +175,8 @@ export async function runConnect(
159
175
  cwd: string = process.cwd(),
160
176
  ): Promise<void> {
161
177
  const { baseUrl, claimId } = parseClaimUrl(claimUrl);
178
+ // #613: resolved BEFORE any network call, so a contradiction fails early.
179
+ const authPath = connectAuthPath(baseUrl, cwd);
162
180
 
163
181
  const fingerprint = clientFingerprint(cwd);
164
182
  process.stderr.write(
@@ -191,14 +209,11 @@ export async function runConnect(
191
209
  }
192
210
  const data = (await pollResp.json()) as PollResponse;
193
211
  if (data.status === "consumed") {
194
- writeAuthJson(cwd, data, fingerprint);
212
+ writeAuthJson(cwd, data, fingerprint, authPath);
195
213
  process.stdout.write(
196
214
  `\n✓ Connected as ${data.user_email} to project '${data.project_name}'.\n` +
197
- ` Wrote ${authFilePath(cwd)}\n\n` +
198
- `Next steps:\n` +
199
- ` 1. Register the channel MCP server (one-time per machine):\n` +
200
- ` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +
201
- ` 2. Restart Claude Code in this directory.\n\n`,
215
+ ` Wrote ${authPath}\n\n` +
216
+ (isQaBackend(baseUrl) ? QA_NEXT_STEPS : DEFAULT_NEXT_STEPS),
202
217
  );
203
218
  return;
204
219
  }
@@ -26,9 +26,10 @@ export interface ControlEvent {
26
26
  data: string;
27
27
  }
28
28
 
29
- /** Build the instance control-stream SSE URL. */
29
+ /** Build the instance control-stream SSE URL. `presence=runner` (#627): this agent's
30
+ * runner vouches for it, so its connect must not count as liveness on the server. */
30
31
  export function buildControlStreamUrl(baseUrl: string, instanceId: string): string {
31
- return `${baseUrl}/api/instances/${instanceId}/events`;
32
+ return `${baseUrl}/api/instances/${instanceId}/events?presence=runner`;
32
33
  }
33
34
 
34
35
  /**