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.
- package/lib/own_join.ts +22 -0
- package/lib/runner_registry.ts +103 -1
- package/lib/runner_roster.ts +39 -1
- package/package.json +1 -1
- package/viber-channel.ts +15 -1
package/lib/own_join.ts
ADDED
|
@@ -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/runner_registry.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/runner_roster.ts
CHANGED
|
@@ -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.
|
|
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) =>
|
|
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
|