viber-channel 0.8.5 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/dm_stream.ts CHANGED
@@ -27,6 +27,21 @@ export interface DmMessage {
27
27
  artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
28
28
  }
29
29
 
30
+ /**
31
+ * Persistent cross-run dedup watermark for ONE DM conversation (#427). Holds the
32
+ * highest message id already DELIVERED to the agent in a prior `runDmStream`.
33
+ * The caller keeps one of these per conversation and passes it back on every
34
+ * (re-)join, so it SURVIVES the abort+restart cycle that `startDmStream` does on
35
+ * a re-pushed join. Mutated in place. Without it every re-join allocates a fresh
36
+ * `handledIds`, re-fetches the full history, and replays every already-delivered
37
+ * message (the "pong loop" the agent saw) — the run-scoped `handledIds` cannot
38
+ * catch that because it is empty at the start of each run.
39
+ */
40
+ export interface DmWatermark {
41
+ /** Highest delivered message id (0 = nothing delivered yet). */
42
+ lastId: number;
43
+ }
44
+
30
45
  /** Signature of the catch-up fetch (mirrors messages.ts `fetchMessages`). */
31
46
  export type FetchMessagesFn = (
32
47
  baseUrl: string,
@@ -66,6 +81,16 @@ export interface DmStreamOptions {
66
81
  * forward anything published while we were disconnected (#291 step-03).
67
82
  */
68
83
  fetchMessagesImpl?: FetchMessagesFn;
84
+ /**
85
+ * Cross-run dedup watermark (#427). The run-scoped `handledIds` only dedups
86
+ * WITHIN a single run; a re-join starts a fresh run with an empty set and the
87
+ * catch-up re-fetches the whole history. Supplying a caller-owned watermark
88
+ * (persisted across joins) suppresses ids already delivered in a prior run,
89
+ * while ids ABOVE the watermark still fire — so a message that arrived during
90
+ * detachment is preserved (#291 trigger-on-join). Advanced only after a
91
+ * successful delivery. Omitted → no cross-run dedup (legacy behaviour).
92
+ */
93
+ watermark?: DmWatermark;
69
94
  }
70
95
 
71
96
  /** Resolve after `ms`, or early when `signal` aborts. */
@@ -106,6 +131,20 @@ async function forwardMessage(
106
131
  ? String(msg.id)
107
132
  : undefined;
108
133
  if (idStr && handledIds.has(idStr)) return; // already forwarded (live or catch-up)
134
+ // Cross-run dedup (#427): the message id is `INTEGER PRIMARY KEY AUTOINCREMENT`
135
+ // in D1 (migration 0009), so the server assigns a STRICTLY INCREASING, never-
136
+ // reused id at insert — a genuinely new message always outranks everything
137
+ // already delivered. Hence any id at or below the watermark was already
138
+ // DELIVERED in a prior run and a re-join's full-history re-fetch must not replay
139
+ // it; ids ABOVE the watermark are new (incl. a trigger that arrived while
140
+ // detached) and still fire (#291). The AUTOINCREMENT guarantee is why a
141
+ // watermark is sufficient and a per-id Set is not needed (review: Opus/Codex).
142
+ // Use isSafeInteger (not isFinite) so an id beyond 2^53 — where Number() loses
143
+ // precision and could collide — falls through to normal handling + the in-run
144
+ // handledIds filter rather than silently mis-gating. The watermark only ever
145
+ // advances AFTER a successful delivery, so a NaN/absent id is never watermarked.
146
+ const numId = idStr !== undefined ? Number(idStr) : Number.NaN;
147
+ if (opts.watermark && Number.isSafeInteger(numId) && numId <= opts.watermark.lastId) return;
109
148
  // Content guard BEFORE consuming the id: a content-less payload must not burn
110
149
  // its id in handledIds, or a later (correctly-populated) catch-up of the same
111
150
  // id would be suppressed forever (review: Opus #4).
@@ -121,6 +160,12 @@ async function forwardMessage(
121
160
  try {
122
161
  await opts.onMessage(normalized);
123
162
  if (idStr) handledIds.add(idStr);
163
+ // Advance the cross-run watermark ONLY after a successful delivery (#427,
164
+ // Opus guard): a crash mid-handling leaves the id un-watermarked so the next
165
+ // catch-up retries it — at-least-once over at-most-once, same as handledIds.
166
+ if (opts.watermark && Number.isSafeInteger(numId) && numId > opts.watermark.lastId) {
167
+ opts.watermark.lastId = numId;
168
+ }
124
169
  } catch (handlerErr) {
125
170
  opts.log?.(
126
171
  `[dm-stream] onMessage handler error: ${handlerErr instanceof Error ? handlerErr.message : String(handlerErr)}\n`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
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": {
package/viber-channel.ts CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  runPersistentControlStream,
48
48
  sendInstanceHeartbeat,
49
49
  } from "./lib/control_stream.ts";
50
- import { runDmStream } from "./lib/dm_stream.ts";
50
+ import { type DmWatermark, runDmStream } from "./lib/dm_stream.ts";
51
51
  import { isOwnMessage } from "./lib/self_echo.ts";
52
52
  import {
53
53
  createTokenRefreshScheduler,
@@ -654,6 +654,19 @@ let INSTANCE_TOKEN = "";
654
654
  // starts a new one, so a stuck/stale stream can never block a fresh join.
655
655
  const activeDmStreams = new Map<string, AbortController>();
656
656
 
657
+ // Cross-run dedup watermark per DM conversation (#427). `startDmStream` aborts
658
+ // and RESTARTS runDmStream on every re-pushed join, and each run's `handledIds`
659
+ // starts empty + re-fetches the full history — which replayed every already-
660
+ // delivered message to the agent (the "pong loop"). This map persists the
661
+ // highest delivered id ACROSS those restarts so a re-join only surfaces genuinely
662
+ // new messages. Kept for the process lifetime and NOT purged on stream end:
663
+ // dropping an entry while a re-join is possible would replay the whole history
664
+ // again (Opus's purge-safety nuance). Growth is O(distinct DM conversations seen)
665
+ // — NOT O(1) and NOT bounded by concurrent streams — but each entry is a single
666
+ // number and the distinct-peer count is small in practice, so it is acceptable
667
+ // without a TTL. Revisit if conversation_ids ever churn (many ephemeral DMs).
668
+ const dmWatermarks = new Map<string, DmWatermark>();
669
+
657
670
  try {
658
671
  // #269: acquire this channel's server-issued instance identity — env-injected
659
672
  // VIBER_INSTANCE_TOKEN > persisted auth.json instance_token > register a new
@@ -1136,6 +1149,13 @@ function startDmStream(minted: ConversationMintResponse): void {
1136
1149
  }
1137
1150
  const controller = new AbortController();
1138
1151
  activeDmStreams.set(minted.conversation_id, controller);
1152
+ // Reuse this conversation's persistent watermark (or seed one) so the restart
1153
+ // above does not replay history already delivered in the prior run (#427).
1154
+ let watermark = dmWatermarks.get(minted.conversation_id);
1155
+ if (!watermark) {
1156
+ watermark = { lastId: 0 };
1157
+ dmWatermarks.set(minted.conversation_id, watermark);
1158
+ }
1139
1159
  process.stderr.write(`[viber-channel] joining agent DM ${minted.conversation_id}\n`);
1140
1160
  // The agent's liveness (and thus the DM's presence in the "Active" sidebar) is
1141
1161
  // proven by the INSTANCE heartbeat, not a per-conversation beat — the legacy
@@ -1147,6 +1167,7 @@ function startDmStream(minted: ConversationMintResponse): void {
1147
1167
  conversationToken: minted.conversation_token,
1148
1168
  log: (m) => process.stderr.write(m),
1149
1169
  signal: controller.signal,
1170
+ watermark,
1150
1171
  onMessage: (msg) => pushMessage(msg, { conversationId: minted.conversation_id }),
1151
1172
  }).finally(() => {
1152
1173
  // Only evict if WE are still the registered controller — a newer join may
@@ -7,6 +7,7 @@
7
7
  * Codex's final assistant reply back into Viber.
8
8
  */
9
9
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
10
+ import { createHash } from "node:crypto";
10
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
12
  import { dirname, join } from "node:path";
12
13
  import { authFilePath, loadAuth } from "./lib/auth.ts";
@@ -242,33 +243,81 @@ function buildReply(receivedContent: string, msg: ConversationMessage | undefine
242
243
  // the shared runConversationStream now owns token refresh, so the bridge no
243
244
  // longer wires it directly.
244
245
 
245
- // #280 step-12: the state file is per AGENT. Several agents on one machine each own
246
- // their own file, so concurrent read-modify-write of a single shared JSON can't drop
247
- // another agent's thread entry (Codex review P2-A). An explicit
248
- // VIBER_CODEX_BRIDGE_STATE override still wins (single file, caller's responsibility).
249
- function stateFilePath(instanceId?: string, cwd: string = process.cwd()): string {
246
+ // #409: monotonic (perf) timing for diagnosing codex spawn / first-response
247
+ // latency. Structured, greppable `timing:` lines — the user's "codex is slow" is
248
+ // largely INHERENT (app-server initialize + first model turn), so we MEASURE where
249
+ // the time goes rather than guess. NOT a behaviour change.
250
+ const nowMs = (): number => performance.now();
251
+ // #409 (Codex): classify a conversation's turn as first vs subsequent and RESERVE
252
+ // the "first" label atomically (before any await), so two near-simultaneous
253
+ // messages can't both be logged `which=first`. Pure/testable.
254
+ export function classifyTurn(
255
+ seen: Set<string>,
256
+ convId: string,
257
+ ): "first" | "subsequent" {
258
+ if (seen.has(convId)) return "subsequent";
259
+ seen.add(convId);
260
+ return "first";
261
+ }
262
+ function logTiming(phase: string, ms: number, extra = ""): void {
263
+ process.stderr.write(
264
+ `[viber-codex-bridge] timing: ${phase} ms=${Math.round(ms)}${extra ? ` ${extra}` : ""}\n`,
265
+ );
266
+ }
267
+
268
+ // #414: the persistent thread identity. #280 keyed the thread by the server-issued
269
+ // instanceId so multiple agents sharing one conversation each get their own Codex
270
+ // thread — but the instanceId is FRESH on every register (#269), so a same-name
271
+ // re-spawn never found its predecessor's thread (thread/start instead of resume,
272
+ // runtime memory lost). Fix: when an EXPLICIT VIBER_CODEX_BRIDGE_LABEL is set (the
273
+ // stable logical name — vibe-master always sets it to the agent id), key by
274
+ // sha256(label) so a re-spawn under the same name resumes; distinct labels stay
275
+ // isolated (#280 preserved). No explicit label → fall back to the instanceId (no
276
+ // resume, but #280 isolation kept — the DEFAULT `Codex bridge • <cwd>` label is NOT
277
+ // a unique identity, and an empty label must never collapse everyone onto
278
+ // hash("")). The hash is filename-safe (hex) and doesn't leak the label.
279
+ export function threadIdentity(
280
+ instanceId: string,
281
+ label: string | undefined,
282
+ ): { key: string; fileSuffix: string } {
283
+ const trimmed = label?.trim();
284
+ if (trimmed) {
285
+ const h = createHash("sha256").update(trimmed, "utf8").digest("hex");
286
+ return { key: `label:${h}`, fileSuffix: `label-${h}` };
287
+ }
288
+ return { key: `instance:${instanceId}`, fileSuffix: `instance-${instanceId}` };
289
+ }
290
+
291
+ // #280 step-12 / #414: the state file is per AGENT IDENTITY (stable label when set,
292
+ // else instanceId). It lives in the workspace's `.viber` dir (per-folder), so two
293
+ // folders — even a cross-project #307 pair using the same explicit label — write
294
+ // DIFFERENT files; the fingerprint in stateKey is defence-in-depth. Concurrent RMW
295
+ // of one file across agents can't drop another's entry (distinct identities →
296
+ // distinct files). An explicit VIBER_CODEX_BRIDGE_STATE override still wins.
297
+ function stateFilePath(
298
+ instanceId: string,
299
+ label: string | undefined,
300
+ cwd: string = process.cwd(),
301
+ ): string {
250
302
  const override = process.env.VIBER_CODEX_BRIDGE_STATE;
251
303
  if (override !== undefined && override.trim() !== "") return override;
252
- // Full instance id in the filename (server-issued opaque hex): a truncated id
253
- // could collide and reintroduce the shared-file write race this split avoids.
254
- const name =
255
- instanceId && instanceId.trim() !== ""
256
- ? `codex-bridge-threads-${instanceId}.json`
257
- : "codex-bridge-threads.json";
258
- return join(dirname(authFilePath(cwd)), name);
304
+ const { fileSuffix } = threadIdentity(instanceId, label);
305
+ return join(dirname(authFilePath(cwd)), `codex-bridge-threads-${fileSuffix}.json`);
259
306
  }
260
307
 
261
- // #280 step-12: the key is per (conversation, AGENT), not per conversation. With
262
- // several agents sharing one conversation, a conversation-only key would make them
263
- // resume/clobber the same Codex thread. Including the instance id gives each agent
264
- // its own persisted thread.
308
+ // #280/#414: the key is per (project, folder, conversation, AGENT IDENTITY). The
309
+ // conversationId is KEPT (a same agent on TWO conversations must not share one
310
+ // runtime thread — cross-conversation contamination); the fingerprint scopes it
311
+ // per folder; the identity is the stable label (or instanceId fallback).
265
312
  export function stateKey(
266
313
  projectId: number,
267
314
  fingerprint: string,
268
315
  conversationId: string,
269
316
  instanceId: string,
317
+ label: string | undefined,
270
318
  ): string {
271
- return `${projectId}:${fingerprint}:${conversationId}:${instanceId}`;
319
+ const { key } = threadIdentity(instanceId, label);
320
+ return `${projectId}:${fingerprint}:${conversationId}:${key}`;
272
321
  }
273
322
 
274
323
  function readThreadState(path: string): ThreadState {
@@ -584,11 +633,13 @@ class CodexAppServer {
584
633
  }
585
634
 
586
635
  async initialize(): Promise<void> {
636
+ const t0 = nowMs(); // #409: measure the app-server init (a big share of spawn latency)
587
637
  await this.request("initialize", {
588
638
  clientInfo: { name: "viber-codex-bridge", title: "Viber Codex Bridge", version: "0.1.0" },
589
639
  capabilities: { experimentalApi: true },
590
640
  });
591
641
  this.notify("initialized");
642
+ logTiming("codex.initialize", nowMs() - t0);
592
643
  }
593
644
 
594
645
  async ensureThread(options: {
@@ -598,18 +649,25 @@ class CodexAppServer {
598
649
  instanceKey: string;
599
650
  newThread: boolean;
600
651
  }): Promise<string> {
601
- const path = stateFilePath(options.instanceKey);
652
+ // #414: prefer the STABLE explicit label over the fresh instanceId so a
653
+ // same-name re-spawn resumes its own thread (see threadIdentity).
654
+ const label = process.env.VIBER_CODEX_BRIDGE_LABEL;
655
+ const path = stateFilePath(options.instanceKey, label);
602
656
  const state = readThreadState(path);
603
657
  const key = stateKey(
604
658
  options.auth.project_id,
605
659
  options.fingerprint,
606
660
  options.conversationId,
607
661
  options.instanceKey,
662
+ label,
608
663
  );
609
664
  const existing = state.threads[key]?.thread_id;
665
+ const t0 = nowMs(); // #409: measure thread resume/start (the first-DM cost)
666
+ let resumeAttempted = false;
610
667
 
611
668
  if (existing && !options.newThread) {
612
669
  try {
670
+ resumeAttempted = true;
613
671
  process.stderr.write(`[viber-codex-bridge] codex: resuming persisted thread ${existing}\n`);
614
672
  const resumed = await this.request("thread/resume", {
615
673
  threadId: existing,
@@ -620,7 +678,11 @@ class CodexAppServer {
620
678
  developerInstructions: AGENT_INSTRUCTIONS,
621
679
  excludeTurns: true,
622
680
  });
623
- return extractThreadId(resumed);
681
+ // #409 (Codex): validate/extract the id BEFORE logging success — a malformed
682
+ // response must NOT be logged as resume then again as resume-failed→start.
683
+ const resumedId = extractThreadId(resumed);
684
+ logTiming("ensureThread", nowMs() - t0, "outcome=resume");
685
+ return resumedId;
624
686
  } catch (err) {
625
687
  process.stderr.write(
626
688
  `[viber-codex-bridge] codex: resume failed (${safeErrorMessage(err)}), starting a fresh thread\n`,
@@ -651,6 +713,11 @@ class CodexAppServer {
651
713
  };
652
714
  writeThreadState(path, state);
653
715
  process.stderr.write(`[viber-codex-bridge] codex: persisted thread ${threadId} in ${path}\n`);
716
+ logTiming(
717
+ "ensureThread",
718
+ nowMs() - t0,
719
+ `outcome=${resumeAttempted ? "resume-failed→start" : "start"}`,
720
+ );
654
721
  return threadId;
655
722
  }
656
723
 
@@ -1166,9 +1233,15 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1166
1233
 
1167
1234
  // codex's per-conversation adapter for the shared runConversationStream:
1168
1235
  // ensureThread (keyed by conversation+agent) then the tracked, queued codex turn.
1236
+ // #409: which conversations have already run their FIRST model turn, so we can
1237
+ // report the (expensive) first turn distinctly from the fast subsequent ones.
1238
+ const firstTurnDone = new Set<string>();
1169
1239
  const makeCodexAdapter = (codex: CodexAppServer, instanceKey: string): MakeRunTurn =>
1170
1240
  async (convId, runtime) => {
1241
+ // #409: time join → thread ready (the eager ensureThread on a fresh join).
1242
+ const tJoin = nowMs();
1171
1243
  let threadPromise = threadCache.get(convId);
1244
+ const cached = threadPromise !== undefined;
1172
1245
  if (!threadPromise) {
1173
1246
  threadPromise = codex
1174
1247
  .ensureThread({ auth, fingerprint, conversationId: convId, instanceKey, newThread: options.newThread })
@@ -1179,8 +1252,22 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1179
1252
  threadCache.set(convId, threadPromise);
1180
1253
  }
1181
1254
  const codexThreadId = await threadPromise;
1255
+ if (!cached) logTiming("join→thread-ready", nowMs() - tJoin, `conv=${convId.slice(0, 8)}`);
1182
1256
  process.stderr.write(`${LOG_PREFIX} codex thread ready for ${convId} (thread ${codexThreadId})\n`);
1183
- return makeCodexRunTurn(codex, codexThreadId, runtime, options, queue, tracker);
1257
+ const runTurn = makeCodexRunTurn(codex, codexThreadId, runtime, options, queue, tracker);
1258
+ // #409: wrap to measure the FIRST model turn (cold: the biggest perceived
1259
+ // latency) apart from subsequent turns. Pure instrumentation, same behaviour.
1260
+ return async (content, msg, signal) => {
1261
+ // #409 (Codex): reserve first/subsequent BEFORE the await so concurrent
1262
+ // turns can't both be classified `first`.
1263
+ const which = classifyTurn(firstTurnDone, convId);
1264
+ const tTurn = nowMs();
1265
+ try {
1266
+ return await runTurn(content, msg, signal);
1267
+ } finally {
1268
+ logTiming("turn", nowMs() - tTurn, `which=${which} conv=${convId.slice(0, 8)}`);
1269
+ }
1270
+ };
1184
1271
  };
1185
1272
 
1186
1273
  if (options.awaitInvite) {