viber-channel 0.8.37 → 0.8.39

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.
@@ -194,11 +194,20 @@ export async function messageAgent(
194
194
  }
195
195
  if (!outcome.ok) {
196
196
  let friendly: string;
197
- if (outcome.detail === "target_offline") {
197
+ if (outcome.detail === "target_offline" && outcome.liveInstanceIds) {
198
+ // #692: the id is stale and its agent is online under several ids (a
199
+ // reconnect race) — the server refused to guess.
200
+ friendly =
201
+ `${trimmedPeerId} is a stale id: that agent reconnected and is online under ` +
202
+ `${outcome.liveInstanceIds.join(", ")}. Call message_agent again with one of them.`;
203
+ } else if (outcome.detail === "target_offline") {
198
204
  // #627: a just-launched agent comes online with its runner's first beat (≤ 30 s).
205
+ // #692: an agent that reconnected under a new id is followed by the server;
206
+ // reaching here means no live instance of it was found under any id.
199
207
  friendly =
200
208
  "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.";
209
+ "If it was just launched, it comes online with its runner's next beat (within 30 s): retry then. " +
210
+ "If you expected it online, check list_agents: it may have been relaunched under another name.";
202
211
  } else if (outcome.detail === "dm_unavailable_retry") {
203
212
  friendly = "Could not open the DM due to a transient conflict — try message_agent again.";
204
213
  } else {
@@ -233,6 +242,14 @@ export async function messageAgent(
233
242
  if (!result.ok) {
234
243
  return errorText(result.detail);
235
244
  }
245
+ if (outcome.redirect) {
246
+ // #692: say which agent was reached, and that the id used is stale.
247
+ return text(
248
+ `Message sent to agent ${outcome.redirect.to} (DM ${outcome.dm.conversation_id}). ` +
249
+ `Note: ${outcome.redirect.from} is a stale id — the same agent reconnected as ` +
250
+ `${outcome.redirect.to}; use that id from now on.`,
251
+ );
252
+ }
236
253
  return text(`Message sent to agent (DM ${outcome.dm.conversation_id}).`);
237
254
  }
238
255
 
@@ -115,7 +115,8 @@ export const TOOL_DEFS = [
115
115
  "(from list_agents) and the text. Opens or reuses a private 1:1 DM and posts your message; " +
116
116
  "the agent's reply arrives back on this channel. Targets are normally same-project; an " +
117
117
  "ORCHESTRATOR instance (#307) can also message agents of the owner's other projects. " +
118
- "Fails if the target agent is offline.",
118
+ "Fails if the target agent is offline. An id gone stale because the agent reconnected " +
119
+ "(same name, new id) still reaches it: the result then names the new id to use (#692).",
119
120
  inputSchema: {
120
121
  type: "object",
121
122
  properties: {
@@ -104,7 +104,8 @@ export const CLAUDE_TOOL_DEFS = [
104
104
  "the agent's reply arrives back on this channel tagged source=agent-dm. " +
105
105
  "Targets are normally same-project; an ORCHESTRATOR instance (#307) can also message agents " +
106
106
  "of the owner's other projects (ids from its cross-project list_agents). " +
107
- "Fails if the target agent is offline.",
107
+ "Fails if the target agent is offline. An id gone stale because the agent reconnected " +
108
+ "(same name, new id) still reaches it: the result then names the new id to use (#692).",
108
109
  inputSchema: {
109
110
  type: "object" as const,
110
111
  properties: {
@@ -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
+ }
package/lib/peers.ts CHANGED
@@ -153,9 +153,22 @@ export interface OpenDmResult {
153
153
  invited: boolean;
154
154
  }
155
155
 
156
+ /** #692: the server opened the DM with the live instance of the identity the
157
+ * caller named by a stale id. */
158
+ export interface OpenDmRedirect {
159
+ from: string;
160
+ to: string;
161
+ }
162
+
156
163
  export type OpenDmOutcome =
157
- | { ok: true; dm: OpenDmResult }
158
- | { ok: false; detail: string; status: number };
164
+ | { ok: true; dm: OpenDmResult; redirect?: OpenDmRedirect }
165
+ | { ok: false; detail: string; status: number; liveInstanceIds?: string[] };
166
+
167
+ /** Server-supplied ids end up in the model's prompt: only an instance id shape
168
+ * (32 lowercase hex) is ever echoed back, never free text. */
169
+ function instanceIdOrNull(v: unknown): string | null {
170
+ return typeof v === "string" && /^[0-9a-f]{32}$/.test(v) ? v : null;
171
+ }
159
172
 
160
173
  /**
161
174
  * Open-or-create a 1:1 DM with `peerInstanceId` (POST /api/instances/dms).
@@ -180,13 +193,20 @@ export async function openDm(
180
193
 
181
194
  if (!resp.ok) {
182
195
  let detail = `HTTP ${resp.status}`;
196
+ let liveInstanceIds: string[] | undefined;
183
197
  try {
184
- const json = (await resp.json()) as { detail?: string };
198
+ const json = (await resp.json()) as { detail?: string; live_instance_ids?: unknown };
185
199
  if (typeof json.detail === "string") detail = json.detail;
200
+ // #692: several live instances of a stale id's identity — the server
201
+ // refused to guess and named them.
202
+ if (Array.isArray(json.live_instance_ids)) {
203
+ const ids = json.live_instance_ids.map(instanceIdOrNull).filter((id): id is string => id !== null);
204
+ if (ids.length > 0) liveInstanceIds = ids;
205
+ }
186
206
  } catch {
187
207
  /* non-JSON body */
188
208
  }
189
- return { ok: false, detail, status: resp.status };
209
+ return { ok: false, detail, status: resp.status, ...(liveInstanceIds ? { liveInstanceIds } : {}) };
190
210
  }
191
211
 
192
212
  const body = (await resp.json()) as {
@@ -194,6 +214,8 @@ export async function openDm(
194
214
  conversation_token?: string;
195
215
  expires_at?: number;
196
216
  invited?: boolean;
217
+ peer_instance_id?: unknown;
218
+ redirected_from?: unknown;
197
219
  };
198
220
  if (!body.conversation?.id || typeof body.conversation_token !== "string") {
199
221
  throw new Error("openDm: malformed response (missing conversation id or token)");
@@ -206,5 +228,14 @@ export async function openDm(
206
228
  expires_at: typeof body.expires_at === "number" ? body.expires_at : 0,
207
229
  invited: body.invited === true,
208
230
  },
231
+ ...redirectOf(body.redirected_from, body.peer_instance_id),
209
232
  };
210
233
  }
234
+
235
+ /** #692: both ids well-formed and distinct, or no redirect at all. An older
236
+ * server sends neither field. */
237
+ function redirectOf(from: unknown, to: unknown): { redirect?: OpenDmRedirect } {
238
+ const f = instanceIdOrNull(from);
239
+ const t = instanceIdOrNull(to);
240
+ return f && t && f !== t ? { redirect: { from: f, to: t } } : {};
241
+ }
@@ -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.39",
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