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.
- package/lib/agent_tools.ts +19 -2
- package/lib/bridge_tool_host.ts +2 -1
- package/lib/claude_tool_defs.ts +2 -1
- package/lib/own_join.ts +22 -0
- package/lib/peers.ts +35 -4
- 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/agent_tools.ts
CHANGED
|
@@ -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
|
|
package/lib/bridge_tool_host.ts
CHANGED
|
@@ -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: {
|
package/lib/claude_tool_defs.ts
CHANGED
|
@@ -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: {
|
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/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
|
+
}
|
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.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) =>
|
|
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
|