viber-channel 0.8.37 → 0.8.38

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.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * own_join.ts — a `join` pushed for the channel's OWN main conversation (#687).
3
+ *
4
+ * Measured on staging: an invite of this instance into the conversation it already
5
+ * streams (the web invite, or the relaunch handover of #627) rotates its member token and
6
+ * pushes a `join`. Opening a second stream on that conversation with the same identity
7
+ * makes Python evict the first one with `stop` (#315), and the channel reads `stop` as a
8
+ * revocation and exits — the agent dies with exit 0 and nothing in its MCP log.
9
+ *
10
+ * So a join for the main conversation never opens a stream: the channel ADOPTS the new
11
+ * token (the old one was replaced server-side) and keeps its one stream. Any other
12
+ * conversation is a DM/room join, as before.
13
+ *
14
+ * `mainConversationId` is the conversation the channel ADOPTED (after mint or reattach),
15
+ * never `VIBER_TARGET_CONVERSATION_ID` alone: a channel that minted its own conversation
16
+ * has no target, and one whose target reattach fell back to a mint holds another id.
17
+ */
18
+ export type JoinRoute = "adopt-main-token" | "open-stream";
19
+
20
+ export function routeJoin(pushedConversationId: string, mainConversationId: string): JoinRoute {
21
+ return mainConversationId !== "" && pushedConversationId === mainConversationId ? "adopt-main-token" : "open-stream";
22
+ }
@@ -32,7 +32,7 @@
32
32
  * which is the one loss JP asked us never to cause.
33
33
  */
34
34
  import { createHash } from "node:crypto";
35
- import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
35
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
36
36
  import { dirname, join } from "node:path";
37
37
  import { presenceDir } from "./presence_record.js";
38
38
 
@@ -424,3 +424,105 @@ export function markClosed(registry: Registry, instanceIds: readonly string[], n
424
424
  }
425
425
  return changed;
426
426
  }
427
+
428
+ // ── #687: close requests — the runner stays the ONLY writer of its registry ──────────────────
429
+ //
430
+ // A dissolve kills, THEN revokes: by then the runner has reported the agent `down` (ambiguous:
431
+ // greyed, relaunchable) and deleted its record, so it never learns the end was decided — measured,
432
+ // a dissolved and revoked agent stayed `active`. The dissolve must not write the registry itself:
433
+ // `updateRegistry` is read-modify-write with no cross-process lock, and a second writer loses the
434
+ // runner's own updates (review Codex). It drops a REQUEST file instead; the runner applies it on its
435
+ // next beat, inside its own write, and removes it after that write succeeded.
436
+
437
+ const CLOSE_REQUEST_RE = /^([0-9a-f]{32})\.json$/;
438
+
439
+ export function closeRequestsDir(identityBaseUrl: string, lockDir?: string): string {
440
+ return join(runnerStateDir(identityBaseUrl, lockDir), "close-requests");
441
+ }
442
+
443
+ /**
444
+ * Ask EVERY runner of this machine to close these instances. Every one, not the one whose identity
445
+ * the caller believes in: the registry is keyed by the runner's identity base URL, which the caller's
446
+ * env may spell differently (review Opus) — and an instance id is globally unique, so a request in
447
+ * another backend's runner matches nothing there. Returns how many runner directories were found.
448
+ */
449
+ export function requestCloseInEveryRunner(instanceIds: readonly string[], lockDir: string, now: number): number {
450
+ const root = join(lockDir, "presence");
451
+ let backends: string[];
452
+ try {
453
+ backends = readdirSync(root);
454
+ } catch {
455
+ return 0;
456
+ }
457
+ let dirs = 0;
458
+ for (const b of backends) {
459
+ const runnerDir = join(root, b, "runner");
460
+ if (!existsSync(runnerDir)) continue;
461
+ dirs++;
462
+ for (const id of instanceIds) {
463
+ if (!/^[0-9a-f]{32}$/.test(id)) continue;
464
+ atomicWriteJson(join(runnerDir, "close-requests", `${id}.json`), { instance_id: id, requested_at: now });
465
+ }
466
+ }
467
+ return dirs;
468
+ }
469
+
470
+ export interface CloseRequest {
471
+ instance_id: string;
472
+ /** Epoch ms; 0 when the file could not be read (then it is expired at once). */
473
+ requested_at: number;
474
+ }
475
+
476
+ /**
477
+ * A request is kept until an entry carrying its instance is CLOSED (review Codex): one read before
478
+ * the beat joined the instance to its entry would otherwise be consumed with nothing closed. A
479
+ * request that never matches — the copies sent to the machine's OTHER live runners — expires after
480
+ * `CLOSE_REQUEST_TTL_MS`. ⚠ Only a runner that BEATS expires anything: in the directory of a runner
481
+ * that no longer runs, nobody reads them, and they stay (harmless: a few bytes, matching nothing).
482
+ */
483
+ export const CLOSE_REQUEST_TTL_MS = 24 * 60 * 60 * 1000;
484
+
485
+ export function readCloseRequests(identityBaseUrl: string, lockDir?: string): CloseRequest[] {
486
+ const dir = closeRequestsDir(identityBaseUrl, lockDir);
487
+ let names: string[];
488
+ try {
489
+ names = readdirSync(dir);
490
+ } catch {
491
+ return [];
492
+ }
493
+ const out: CloseRequest[] = [];
494
+ for (const n of names) {
495
+ const id = CLOSE_REQUEST_RE.exec(n)?.[1];
496
+ if (id === undefined) continue;
497
+ let at = 0;
498
+ try {
499
+ const v: unknown = JSON.parse(readFileSync(join(dir, n), "utf8"));
500
+ if (isObject(v) && typeof v.requested_at === "number") at = v.requested_at;
501
+ } catch {
502
+ // unreadable: expires at once
503
+ }
504
+ out.push({ instance_id: id, requested_at: at });
505
+ }
506
+ return out;
507
+ }
508
+
509
+ /** The requests that are DONE: their instance's entry is closed, or they expired. PURE. */
510
+ export function settledCloseRequests(registry: Registry, requests: readonly CloseRequest[], now: number): string[] {
511
+ const closed = new Set(
512
+ Object.values(registry.entries)
513
+ .filter((e) => e.state === "closed" && e.instance_id !== null)
514
+ .map((e) => e.instance_id as string),
515
+ );
516
+ return requests.filter((q) => closed.has(q.instance_id) || now - q.requested_at > CLOSE_REQUEST_TTL_MS).map((q) => q.instance_id);
517
+ }
518
+
519
+ /** Only after the registry write that applied them succeeded. */
520
+ export function dropCloseRequests(identityBaseUrl: string, ids: readonly string[], lockDir?: string): void {
521
+ for (const id of ids) {
522
+ try {
523
+ unlinkSync(join(closeRequestsDir(identityBaseUrl, lockDir), `${id}.json`));
524
+ } catch {
525
+ // already gone
526
+ }
527
+ }
528
+ }
@@ -90,6 +90,8 @@ export interface CollectedRoster {
90
90
  endedFiles: Map<string, { path: string; content: string }>;
91
91
  /** Why this beat reported nothing it could not prove. */
92
92
  notes: string[];
93
+ /** #687: one line per ended agent OBSERVED (logged even if `capRoster` defers its report), for the runner's log (`endLine`). */
94
+ ends: string[];
93
95
  }
94
96
 
95
97
  /** The process table: pid → start time, or `undefined` when the instrument did not see. */
@@ -108,6 +110,7 @@ function emptyCollected(note?: string): CollectedRoster {
108
110
  seen: [],
109
111
  endedFiles: new Map(),
110
112
  notes: note === undefined ? [] : [note],
113
+ ends: [],
111
114
  };
112
115
  }
113
116
 
@@ -116,6 +119,31 @@ type Classified =
116
119
  | { kind: "closed" | "down"; record: PresenceRecord }
117
120
  | { kind: "unknown"; why: string };
118
121
 
122
+ /**
123
+ * #687 — WHY an agent ended, in the runner's log. Claude Code keeps a channel's stderr only until
124
+ * the MCP connection is up, and the record is deleted once the server acknowledges it: without
125
+ * this line, a channel that exits after its start (measured: a relaunch killed by `stop`) leaves
126
+ * NO trace anywhere. The reason is the one the channel wrote; a record still `active` whose
127
+ * process is gone wrote none.
128
+ */
129
+ export function endLine(record: PresenceRecord, verdict: "closed" | "down"): string {
130
+ const why = record.state === "ended" ? describeEnd(record.ended_reason) : "process gone, no end written";
131
+ return `agent ${record.label ?? "(no label)"} (${record.instance_id}) ended: ${why} -> ${verdict}`;
132
+ }
133
+
134
+ /**
135
+ * ⚠ `revoked` is what the channel writes on ANY server `stop` (`viber-channel.ts`, `case "stop"`):
136
+ * a revocation, an offline sweep, OR a second stream of the same identity superseding the first
137
+ * (#315) — the very cause of #687, which a bare "revoked" would have pointed away from (review
138
+ * Opus). The line says what the word covers. A new reason value is NOT the fix: a runner of an
139
+ * older release refuses a record whose reason it does not know (`END_REASONS`), so it would stop
140
+ * reporting that agent at all.
141
+ */
142
+ function describeEnd(reason: string | null): string {
143
+ if (reason === "revoked") return "revoked (a server `stop`: revocation, offline sweep, or a superseded stream)";
144
+ return reason ?? "unknown";
145
+ }
146
+
119
147
  /** Classify ONE record against the process table. PURE — the witness's subject. */
120
148
  export function classifyRecord(record: PresenceRecord, table: ProcessTable): Classified {
121
149
  if (record.state === "ended") {
@@ -179,6 +207,7 @@ export async function collectRoster(deps: CollectDeps): Promise<CollectedRoster>
179
207
  } else {
180
208
  out.roster[c.kind].push(record.instance_id);
181
209
  out.endedFiles.set(record.instance_id, { path, content: JSON.stringify(record) });
210
+ out.ends.push(endLine(record, c.kind));
182
211
  }
183
212
  }
184
213
  return capRoster(out);
@@ -408,6 +437,8 @@ async function rosterBeatOnce(auth: RosterBeatAuth, deps: RosterBeatDeps): Promi
408
437
  enumerate: deps.enumerate ?? (() => enumerateStartTimes()),
409
438
  });
410
439
  for (const note of collected.notes) noteOnce(note, log);
440
+ // Resent every beat until acknowledged: logged ONCE per distinct line.
441
+ for (const end of collected.ends) noteOnce(end, log);
411
442
  // #627 step-07: an instance REPLACED by a relaunch is reported closed until the
412
443
  // server acknowledges it — so no greyed ghost stays behind the new agent.
413
444
  const { closes: pendingCloses, replaced } = pendingClosesOf(identity, deps.lockDir, collected.roster);
@@ -455,9 +486,14 @@ async function rosterBeatOnce(auth: RosterBeatAuth, deps: RosterBeatDeps): Promi
455
486
  }
456
487
  const removed = applyAck(collected, ack, { identityBaseUrl: identity, lockDir: deps.lockDir, now, log });
457
488
  const closedNow = removed.filter((id) => collected.roster.closed.includes(id));
489
+ // #687: closes REQUESTED by a dissolve (it revoked them after the kill) — applied in THIS write,
490
+ // so the runner stays the registry's only writer, and dropped only once the write succeeded.
491
+ const requested = reg.readCloseRequests(identity, deps.lockDir);
492
+ let settled: string[] = [];
458
493
  const result = reg.updateRegistry(reg.registryPath(identity, deps.lockDir), (r) => {
459
494
  const synced = reg.syncFromRecords(r, collected.seen);
460
- const closed = reg.markClosed(r, closedNow, now());
495
+ const closed = reg.markClosed(r, [...closedNow, ...requested.map((q) => q.instance_id)], now());
496
+ settled = reg.settledCloseRequests(r, requested, now());
461
497
  let cleared = false;
462
498
  if (ack.presence_applied) {
463
499
  for (const e of Object.values(r.entries)) {
@@ -494,6 +530,8 @@ async function rosterBeatOnce(auth: RosterBeatAuth, deps: RosterBeatDeps): Promi
494
530
  return synced || closed || cleared;
495
531
  });
496
532
  if (!result.ok) noteOnce(`registry not updated: ${result.error}`, log);
533
+ // Only what is DONE (its entry closed, or expired): an instance not joined yet keeps its request.
534
+ else if (settled.length > 0) reg.dropCloseRequests(identity, settled, deps.lockDir);
497
535
  try {
498
536
  reg.writeRunnerState(reg.runnerStatePath(identity, deps.lockDir), { runner_id: auth.runner_id, last_beat_ok_at: now() });
499
537
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.37",
3
+ "version": "0.8.38",
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
@@ -72,6 +72,7 @@ import { warnIfStale } from "./lib/version_check.ts";
72
72
  import { capabilitiesText } from "./lib/capabilities.ts";
73
73
  import { readLocalVersion } from "./lib/version_check.ts";
74
74
  import { BOW_OUT_MESSAGE, shouldBowOutToPlugin } from "./lib/cli_bow_out.ts";
75
+ import { routeJoin } from "./lib/own_join.ts";
75
76
  import { resolveLockDir } from "./lib/lock_dir.ts";
76
77
  import { acquireLockFile, createLockSlot } from "./lib/bridge_lock.ts";
77
78
 
@@ -832,7 +833,20 @@ try {
832
833
  { getBaseUrl: apiBase, instanceId: OWN_INSTANCE_ID, instanceToken },
833
834
  {
834
835
  log: (m) => process.stderr.write(m),
835
- onJoin: (pushed) => startDmStream(pushed),
836
+ onJoin: (pushed) => {
837
+ // #687: a join for our OWN main conversation must never open a second stream on it.
838
+ if (routeJoin(pushed.conversation_id, CONVERSATION_ID) === "adopt-main-token") {
839
+ // Every reader takes CONVERSATION_TOKEN at call time (send, ack, SSE headers, refresh).
840
+ // The refresh stays scheduled on the OLD expiry: it fires early, never late, and it
841
+ // presents this adopted token — safe (review Opus + Codex).
842
+ CONVERSATION_TOKEN = pushed.conversation_token;
843
+ process.stderr.write(
844
+ `[viber-channel] join pushed for the main conversation ${CONVERSATION_ID} — token adopted, no second stream (#687)\n`,
845
+ );
846
+ return;
847
+ }
848
+ startDmStream(pushed);
849
+ },
836
850
  onStop: (reason) =>
837
851
  process.stderr.write(`[viber-channel] control stream stopped (${reason})\n`),
838
852
  // #627: this agent never beats on the network — its runner answers for it