viber-channel 0.8.36 → 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.
@@ -62,7 +62,8 @@ export function capabilitiesText(channelVersion: string | null = null): string {
62
62
  " w651-child differs, so that is fine — but pass a distinct --team (w651-child).",
63
63
  "WHERE DO TEMPLATES COME FROM? ONE source (#660): the owner's templates on the web.",
64
64
  " launch-team reads <template> from the web at launch time (with the folder's",
65
- " project token); the web's New team / Add agent use the same templates. No local",
65
+ " project token); the web's New team uses the same templates (its Add agent reads",
66
+ " the role library instead, #673). No local",
66
67
  " file, no built-in, no cache, no fallback: web unreachable, token refused or name",
67
68
  " unknown = an error, and NOTHING is launched (an unknown name lists the available",
68
69
  " ones).",
@@ -116,6 +117,17 @@ export function capabilitiesText(channelVersion: string | null = null): string {
116
117
  " model opus|sonnet|haiku|fable, effort low|medium|high|xhigh|max. On codex BOTH are",
117
118
  ` REQUIRED: ${CODEX_DEFAULT_PAIR} unless the owner asks for another listed pair.`,
118
119
  " (On the web, sidebar ⋯ → Add agent launches ONE named agent from the same library.)",
120
+ "OLD ONE-ROLE TEMPLATES (#678): a template with a single role was only a workaround",
121
+ " to launch one agent before launch-agent existed. Do not create new ones:",
122
+ " launch the role itself (launch-agent, or web Add agent). The #671 migration created",
123
+ " a library role for every template role (same-name variants became <name>.2, .3…),",
124
+ " and a role a live template references cannot be deleted (409 role_in_use): so the",
125
+ " role exists while the template does. Read its exact LIBRARY name BEFORE the template",
126
+ " goes — that is what launch-agent takes: team_roles list, or the spec ref's `role`",
127
+ " (never its `as`, nor resolved_roles, which shows the `as` alias).",
128
+ " Deleting the template is the owner's call — on the web (Settings → Team templates),",
129
+ " or by team_templates delete only on the owner's explicit request.",
130
+ " User guide (no source code needed): https://viber.dgypx.dev/help/agents",
119
131
  "",
120
132
  "DISSOLVE (stops the processes, then revokes the proven-stopped instances):",
121
133
  " viber-gateway dissolve --team <t> | --agent <launch-id> --dir <registry>",
@@ -130,7 +142,9 @@ export function capabilitiesText(channelVersion: string | null = null): string {
130
142
  " An id matching no record prints \"no record <id>\" and EXITS 2: nothing",
131
143
  " was dissolved. Read the exit code directly, never through a pipe (| tail gives 0).",
132
144
  " It never kills its caller. From app 0.7.23 it also revokes ORCHESTRATOR agents, and an",
133
- " agent already dead (its recorded host gone) is proven stopped and revoked. Anything",
145
+ " agent already dead (its recorded host gone) is proven stopped and revoked. #670 (next",
146
+ " app release): it closes a dead agent's terminal window itself, and stops a WEB RELAUNCH",
147
+ " of a member through its instance before revoking it. Anything",
134
148
  " still \"stop NOT proven, not revoked\" prints a \"cause: … — action: …\" line: do",
135
149
  " what it says (kill a launcher, re-run dissolve, or web sidebar ⋯ → Revoke).",
136
150
  "",
@@ -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.36",
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