viber-channel 0.8.3 → 0.8.5
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/auth.ts +23 -6
- package/lib/bridge_core.ts +4 -1
- package/lib/channel_session.ts +83 -0
- package/lib/control_stream.ts +29 -7
- package/lib/conversation.ts +201 -8
- package/lib/heartbeat.ts +57 -5
- package/lib/instance.ts +113 -36
- package/package.json +1 -1
- package/viber-channel.ts +123 -25
- package/viber-codex-bridge.ts +16 -1
package/lib/auth.ts
CHANGED
|
@@ -33,21 +33,38 @@ export interface AuthJson {
|
|
|
33
33
|
instance_id?: string;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The environment signal — SINGLE source of truth for every env-dependent
|
|
38
|
+
* behavior in the channel (auth file, agent-session yield, …). The DEV backend
|
|
39
|
+
* is the only non-default: anything else (unset / staging / prod) uses the
|
|
40
|
+
* STAGING defaults, so a missing or ambiguous signal can never accidentally
|
|
41
|
+
* pull dev behavior into staging. Derived from `VIBER_BASE_URL`, which the
|
|
42
|
+
* launcher always sets per env.
|
|
43
|
+
*/
|
|
44
|
+
export function isDevBackend(
|
|
45
|
+
baseUrl: string = process.env.VIBER_BASE_URL ?? "",
|
|
46
|
+
): boolean {
|
|
47
|
+
return baseUrl.includes("viber-dev");
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
/**
|
|
37
51
|
* Resolve the auth file path.
|
|
38
52
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
53
|
+
* The optional `VIBER_AUTH_FILE` env var overrides everything — absolute paths
|
|
54
|
+
* verbatim, relative paths resolved against `cwd`. With no override the file is
|
|
55
|
+
* DERIVED from the environment (single source of truth, {@link isDevBackend}):
|
|
56
|
+
* the dev backend uses `.viber/dev.auth.json`, everything else defaults to
|
|
57
|
+
* `.viber/auth.json` (staging). This kills the class of bugs where a dev channel
|
|
58
|
+
* silently authenticated against the dev backend with STAGING creds (`-32000`)
|
|
59
|
+
* because only `VIBER_BASE_URL` was set and the auth file defaulted to staging.
|
|
44
60
|
*/
|
|
45
61
|
export function authFilePath(cwd: string = process.cwd()): string {
|
|
46
62
|
const override = process.env.VIBER_AUTH_FILE;
|
|
47
63
|
if (override !== undefined && override.trim() !== "") {
|
|
48
64
|
return isAbsolute(override) ? override : join(cwd, override);
|
|
49
65
|
}
|
|
50
|
-
|
|
66
|
+
const file = isDevBackend() ? "dev.auth.json" : "auth.json";
|
|
67
|
+
return join(cwd, ".viber", file);
|
|
51
68
|
}
|
|
52
69
|
|
|
53
70
|
export function loadAuth(cwd: string = process.cwd()): AuthJson {
|
package/lib/bridge_core.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { buildSseUrl } from "./urls.js";
|
|
|
27
27
|
import { cfAccessHeaders } from "./cfAccess.js";
|
|
28
28
|
import { sessionFilePath, writeHandle } from "./channel_session.js";
|
|
29
29
|
import type { AuthJson } from "./auth.js";
|
|
30
|
-
import { acquireInstance, registerInstance, instanceKindFromEnv, type AcquiredInstance } from "./instance.js";
|
|
30
|
+
import { acquireInstance, registerInstance, instanceKindFromEnv, maybeAttachTeam, type AcquiredInstance } from "./instance.js";
|
|
31
31
|
import {
|
|
32
32
|
runPersistentControlStream,
|
|
33
33
|
sendInstanceHeartbeat,
|
|
@@ -897,6 +897,9 @@ export async function acquireBridgeInstance(opts: {
|
|
|
897
897
|
process.stderr.write(`${logPrefix} instance: registering fresh bridge instance\n`);
|
|
898
898
|
const reg = await registerInstance(baseUrl, auth.project_id, auth.project_token, fingerprint, label, instanceKindFromEnv() ?? kind);
|
|
899
899
|
process.stderr.write(`${logPrefix} instance: registered fresh bridge instance (id=${reg.instance_id})\n`);
|
|
900
|
+
// #398: best-effort self-attach to VIBER_INSTANCE_TEAM (codex/gemma path; the
|
|
901
|
+
// two branches above go through acquireInstance which already self-attaches).
|
|
902
|
+
await maybeAttachTeam(baseUrl, auth.project_id, auth.project_token, fingerprint, reg.instance_id, (m) => process.stderr.write(m));
|
|
900
903
|
return { instance_token: reg.instance_token, instance_key: reg.instance_id };
|
|
901
904
|
}
|
|
902
905
|
|
package/lib/channel_session.ts
CHANGED
|
@@ -50,6 +50,89 @@ export function sessionFilePath(
|
|
|
50
50
|
return join(dir, `channel-session-${suffix}.json`);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Session file path for a **named** channel agent (#400), namespaced by
|
|
55
|
+
* `(baseUrl, projectId, name)` instead of the server `instance_id`.
|
|
56
|
+
*
|
|
57
|
+
* Why a separate key: an agent launched by vibe-master carries a stable
|
|
58
|
+
* `VIBER_CHANNEL_LABEL` (its name) but re-registers a FRESH, non-persisted
|
|
59
|
+
* `instance_id` on every start (`acquireInstance` path 2). Keying the handle by
|
|
60
|
+
* that ephemeral id means it is never found on the next start → the channel
|
|
61
|
+
* mints a new conversation every time (the #400 accumulation). The NAME is the
|
|
62
|
+
* only key that survives across re-spawns and MCP reconnects, so a named agent
|
|
63
|
+
* namespaces its handle by name and finds — and reattaches — its previous
|
|
64
|
+
* conversation.
|
|
65
|
+
*
|
|
66
|
+
* `projectId` is folded in so the same name in two different projects (or
|
|
67
|
+
* worktrees connected to different projects) never collide on one handle file.
|
|
68
|
+
* The plain (un-named) channel keeps `sessionFilePath` keyed by the persisted
|
|
69
|
+
* `instance_id` — do NOT merge the two paths (#269 isolation on plain channel).
|
|
70
|
+
*/
|
|
71
|
+
export function sessionFilePathForName(
|
|
72
|
+
baseUrl: string,
|
|
73
|
+
projectId: number,
|
|
74
|
+
name: string,
|
|
75
|
+
dir: string,
|
|
76
|
+
): string {
|
|
77
|
+
const suffix = createHash("sha256")
|
|
78
|
+
.update(`${baseUrl}\n${projectId}\n${name}`)
|
|
79
|
+
.digest("hex")
|
|
80
|
+
.slice(0, 8);
|
|
81
|
+
return join(dir, `channel-session-${suffix}.json`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Pick the session-handle path for this channel process (#400), applying the
|
|
86
|
+
* named-vs-plain rule in ONE testable place (no `as` cast at the call site).
|
|
87
|
+
*
|
|
88
|
+
* A NAMED agent — a non-empty `channelLabel` (`VIBER_CHANNEL_LABEL`) AND no
|
|
89
|
+
* env-injected `instanceToken` (`VIBER_INSTANCE_TOKEN`) — keys its handle by the
|
|
90
|
+
* stable NAME (`sessionFilePathForName`), because `acquireInstance` path 2
|
|
91
|
+
* re-registers a fresh instance_id every start. Otherwise (plain channel, or an
|
|
92
|
+
* orchestrator-injected stable instance token = path 1) the handle stays keyed
|
|
93
|
+
* by the durable `instanceKey` (`sessionFilePath`) — #269 isolation preserved.
|
|
94
|
+
*
|
|
95
|
+
* Precedence mirrors `acquireInstance`: an env instance token (path 1) wins over
|
|
96
|
+
* a label (path 2), so `instanceToken` present ⇒ instance-keyed even if a label
|
|
97
|
+
* is also set.
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* The stable resume NAME for a named agent session (#400), or `null` for a plain
|
|
101
|
+
* / orchestrator-injected session.
|
|
102
|
+
*
|
|
103
|
+
* A NAMED agent = a non-empty `channelLabel` (VIBER_CHANNEL_LABEL) AND no
|
|
104
|
+
* env-injected `instanceToken` (VIBER_INSTANCE_TOKEN). Such an agent re-registers
|
|
105
|
+
* a fresh instance_id every start (acquireInstance path 2), so its stable
|
|
106
|
+
* identity is the NAME: it keys its handle by name AND resumes by name. An
|
|
107
|
+
* env-injected token (path 1, orchestrator) means a durable instance → NOT a
|
|
108
|
+
* named-resume session (returns null). Single source of truth for both the
|
|
109
|
+
* handle-namespacing and resume-by-name decisions; returns the trimmed name so
|
|
110
|
+
* callers avoid re-deriving it (and no `as` cast is needed).
|
|
111
|
+
*/
|
|
112
|
+
export function namedAgentLabel(
|
|
113
|
+
channelLabel: string | undefined,
|
|
114
|
+
instanceToken: string | undefined,
|
|
115
|
+
): string | null {
|
|
116
|
+
const name = channelLabel?.trim();
|
|
117
|
+
if (name === undefined || name === "") return null;
|
|
118
|
+
if ((instanceToken ?? "").trim() !== "") return null;
|
|
119
|
+
return name;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function resolveSessionFilePath(opts: {
|
|
123
|
+
baseUrl: string;
|
|
124
|
+
projectId: number;
|
|
125
|
+
channelLabel: string | undefined;
|
|
126
|
+
instanceToken: string | undefined;
|
|
127
|
+
instanceKey: string;
|
|
128
|
+
dir: string;
|
|
129
|
+
}): string {
|
|
130
|
+
const name = namedAgentLabel(opts.channelLabel, opts.instanceToken);
|
|
131
|
+
return name !== null
|
|
132
|
+
? sessionFilePathForName(opts.baseUrl, opts.projectId, name, opts.dir)
|
|
133
|
+
: sessionFilePath(opts.baseUrl, opts.instanceKey, opts.dir);
|
|
134
|
+
}
|
|
135
|
+
|
|
53
136
|
/**
|
|
54
137
|
* Read a persisted handle from `path`.
|
|
55
138
|
*
|
package/lib/control_stream.ts
CHANGED
|
@@ -36,17 +36,29 @@ export function buildControlStreamUrl(baseUrl: string, instanceId: string): stri
|
|
|
36
36
|
*
|
|
37
37
|
* The agent beats on a fixed interval so the server's #311 sweeper never purges a
|
|
38
38
|
* live-but-idle agent: a server→client SSE keepalive can't prove this process is
|
|
39
|
-
* still alive behind the Cloudflare tunnel, but this beat can.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* still alive behind the Cloudflare tunnel, but this beat can. Bounded by a
|
|
40
|
+
* per-POST timeout so a hung tunnel socket can't stack beats (mirrors #298).
|
|
41
|
+
*
|
|
42
|
+
* #400 (recadrage): when `conversationId` is given, the server RENEWS this
|
|
43
|
+
* instance's conversation lease and reports `lease_active` — `true` (still held),
|
|
44
|
+
* `false` (a new holder took over → the channel must close), or `null`
|
|
45
|
+
* (indeterminate: not checked, or a D1 error — never a false "still yours").
|
|
42
46
|
*/
|
|
47
|
+
export interface HeartbeatResult {
|
|
48
|
+
/** true on 2xx, false on any non-2xx / network error. */
|
|
49
|
+
ok: boolean;
|
|
50
|
+
/** Lease state from the server (#400): true=held, false=lost, null=indeterminate. */
|
|
51
|
+
leaseActive: boolean | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
export async function sendInstanceHeartbeat(
|
|
44
55
|
baseUrl: string,
|
|
45
56
|
instanceId: string,
|
|
46
57
|
instanceToken: string,
|
|
58
|
+
conversationId: string | null = null,
|
|
47
59
|
timeoutMs = 20_000,
|
|
48
60
|
fetchImpl: typeof fetch = fetch,
|
|
49
|
-
): Promise<
|
|
61
|
+
): Promise<HeartbeatResult> {
|
|
50
62
|
try {
|
|
51
63
|
const resp = await fetchImpl(`${baseUrl}/api/instances/${instanceId}/heartbeat`, {
|
|
52
64
|
method: "POST",
|
|
@@ -55,12 +67,22 @@ export async function sendInstanceHeartbeat(
|
|
|
55
67
|
"Content-Type": "application/json",
|
|
56
68
|
...cfAccessHeaders(),
|
|
57
69
|
},
|
|
58
|
-
body:
|
|
70
|
+
body: JSON.stringify(conversationId !== null ? { conversation_id: conversationId } : {}),
|
|
59
71
|
signal: AbortSignal.timeout(timeoutMs),
|
|
60
72
|
});
|
|
61
|
-
|
|
73
|
+
if (!resp.ok) return { ok: false, leaseActive: null };
|
|
74
|
+
let leaseActive: boolean | null = null;
|
|
75
|
+
try {
|
|
76
|
+
const body = (await resp.json()) as { lease_active?: boolean | null };
|
|
77
|
+
if (body.lease_active === true || body.lease_active === false) {
|
|
78
|
+
leaseActive = body.lease_active;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// no/invalid body — leave indeterminate
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, leaseActive };
|
|
62
84
|
} catch {
|
|
63
|
-
return false;
|
|
85
|
+
return { ok: false, leaseActive: null };
|
|
64
86
|
}
|
|
65
87
|
}
|
|
66
88
|
|
package/lib/conversation.ts
CHANGED
|
@@ -40,6 +40,25 @@ export class ConversationMintError extends Error {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* #400 (recadrage) — an EXPLICIT `--conversation <id>` (VIBER_TARGET_CONVERSATION_ID)
|
|
45
|
+
* could not be loaded, and the operator asked for THAT conversation specifically:
|
|
46
|
+
* we must NOT invent a different one (that would recreate the very session clutter
|
|
47
|
+
* #400 removes). The channel catches this and exits cleanly (clear message,
|
|
48
|
+
* non-zero) — NEVER a fresh mint, never an uncaught stack trace. Distinct from
|
|
49
|
+
* name-resume, which is a heuristic allowed to fall back to a fresh mint.
|
|
50
|
+
*/
|
|
51
|
+
export class ExplicitTargetUnavailableError extends Error {
|
|
52
|
+
conversationId: string;
|
|
53
|
+
reason: string;
|
|
54
|
+
constructor(conversationId: string, reason: string) {
|
|
55
|
+
super(`Requested conversation ${conversationId} ${reason}`);
|
|
56
|
+
this.name = "ExplicitTargetUnavailableError";
|
|
57
|
+
this.conversationId = conversationId;
|
|
58
|
+
this.reason = reason;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
export async function mintConversation(
|
|
44
63
|
baseUrl: string,
|
|
45
64
|
projectId: number,
|
|
@@ -121,13 +140,54 @@ export async function reattachConversation(
|
|
|
121
140
|
}
|
|
122
141
|
throw new ReattachFailedError(code);
|
|
123
142
|
}
|
|
143
|
+
// Non-409 non-2xx (401 revoked, 503 lease-unavailable, other 5xx): throw a
|
|
144
|
+
// typed error carrying the status so the caller can react (401 → re-register;
|
|
145
|
+
// 5xx → explicit-target hard-fail / graceful fallback). #400 (recadrage).
|
|
124
146
|
const detail = await resp.text();
|
|
125
|
-
throw new
|
|
147
|
+
throw new ConversationMintError(resp.status, detail);
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
return (await resp.json()) as ConversationMintResponse;
|
|
129
151
|
}
|
|
130
152
|
|
|
153
|
+
export interface ResumeCandidate {
|
|
154
|
+
conversation_id: string;
|
|
155
|
+
created_at: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* GET /api/instances/resume-candidates?label=<name> (#400) — ask the server
|
|
160
|
+
* which channel-attached conversations carry `label` (the resume-by-name lookup
|
|
161
|
+
* for a named agent that lost its local handle). Auth: instance_token; the owner
|
|
162
|
+
* + project are derived server-side from the validated instance, so `label` is
|
|
163
|
+
* the only input. Returns the candidate list (may be empty). NEVER throws for an
|
|
164
|
+
* empty result — only for a real HTTP/network failure, which the caller treats
|
|
165
|
+
* as "no resume → mint".
|
|
166
|
+
*/
|
|
167
|
+
export async function resumeCandidatesByLabel(
|
|
168
|
+
baseUrl: string,
|
|
169
|
+
instanceToken: string,
|
|
170
|
+
fingerprint: string,
|
|
171
|
+
label: string,
|
|
172
|
+
): Promise<ResumeCandidate[]> {
|
|
173
|
+
const resp = await fetch(
|
|
174
|
+
`${baseUrl}/api/instances/resume-candidates?label=${encodeURIComponent(label)}`,
|
|
175
|
+
{
|
|
176
|
+
method: "GET",
|
|
177
|
+
headers: {
|
|
178
|
+
Authorization: `Bearer ${instanceToken}`,
|
|
179
|
+
"X-Client-Fingerprint": fingerprint,
|
|
180
|
+
...cfAccessHeaders(),
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
if (!resp.ok) {
|
|
185
|
+
throw new Error(`resume-candidates lookup failed (HTTP ${resp.status})`);
|
|
186
|
+
}
|
|
187
|
+
const body = (await resp.json()) as { candidates?: ResumeCandidate[] };
|
|
188
|
+
return Array.isArray(body.candidates) ? body.candidates : [];
|
|
189
|
+
}
|
|
190
|
+
|
|
131
191
|
export interface RefreshTokenResponse {
|
|
132
192
|
conversation_token: string;
|
|
133
193
|
expires_at: number;
|
|
@@ -232,8 +292,14 @@ export function defaultLabel(folderPath: string): string {
|
|
|
232
292
|
}
|
|
233
293
|
|
|
234
294
|
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
295
|
+
* Legacy reattach window in seconds (kept for tests / explicit opt-in).
|
|
296
|
+
*
|
|
297
|
+
* NOTE (#400): this is NO LONGER the channel default. The server `handleReattach`
|
|
298
|
+
* reattaches a conversation of any age (only the token expires; the row persists),
|
|
299
|
+
* so the channel now defaults to an UNBOUNDED window (always attempt reattach) and
|
|
300
|
+
* only caps it when `VIBER_REATTACH_WINDOW_SECONDS` is set to a finite value. The
|
|
301
|
+
* old comment ("a handle older than this points at an expired conversation") was
|
|
302
|
+
* incorrect and was the reason a channel minted a fresh conversation after 1h.
|
|
237
303
|
*/
|
|
238
304
|
export const DEFAULT_REATTACH_WINDOW_SECONDS = 3600;
|
|
239
305
|
|
|
@@ -268,8 +334,68 @@ export async function acquireConversation(
|
|
|
268
334
|
targetConversationId?: string | null,
|
|
269
335
|
reattachWindowSeconds: number = DEFAULT_REATTACH_WINDOW_SECONDS,
|
|
270
336
|
log: (msg: string) => void = (msg) => process.stderr.write(msg),
|
|
337
|
+
/**
|
|
338
|
+
* #400 resume-by-name key: the conversation label a NAMED agent minted with
|
|
339
|
+
* (VIBER_CONVERSATION_LABEL). When non-null AND no local handle reattaches, the
|
|
340
|
+
* server is asked which channel-attached conversation carries this name; a
|
|
341
|
+
* UNIQUE match is reattached, 0/collision → mint. null (plain channel /
|
|
342
|
+
* timestamped label) disables the lookup. Last param so existing positional
|
|
343
|
+
* callers/tests (…, window, log) are unaffected.
|
|
344
|
+
*/
|
|
345
|
+
resumeLabel: string | null = null,
|
|
346
|
+
/**
|
|
347
|
+
* #400 (recadrage) — EXPLICIT per-agent target conversation id (env
|
|
348
|
+
* `VIBER_TARGET_CONVERSATION_ID`, injected by vibe-master `spawn --conversation`).
|
|
349
|
+
* HIGHEST priority: bind THIS conversation by id. On success → use it. On ANY
|
|
350
|
+
* failure (conversation_in_use / revoked-or-missing / 5xx server-unavailable) →
|
|
351
|
+
* HARD FAIL via ExplicitTargetUnavailableError (the channel exits cleanly): NO
|
|
352
|
+
* mint, NO heuristic fallback — the operator asked for a SPECIFIC conversation,
|
|
353
|
+
* so inventing a different one would recreate the clutter #400 removes. A 401
|
|
354
|
+
* (token revoked) still propagates so the outer loop re-registers. (Contrast:
|
|
355
|
+
* name-resume is a heuristic allowed to mint gracefully on failure.) Last param
|
|
356
|
+
* → existing positional callers unaffected.
|
|
357
|
+
*/
|
|
358
|
+
envTargetConversationId: string | null = null,
|
|
271
359
|
): Promise<ConversationMintResponse> {
|
|
272
360
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
361
|
+
|
|
362
|
+
// #400 (recadrage) — EXPLICIT per-agent target wins over every heuristic AND
|
|
363
|
+
// never invents a different conversation. The operator asked for THIS id: bind
|
|
364
|
+
// it, or FAIL cleanly (no fresh mint — that would recreate the clutter #400
|
|
365
|
+
// removes). A 401 still propagates so the outer loop re-registers.
|
|
366
|
+
if (envTargetConversationId !== null) {
|
|
367
|
+
log(`[viber-channel] startup: explicit target ${envTargetConversationId} (VIBER_TARGET_CONVERSATION_ID), attempting reattach\n`);
|
|
368
|
+
try {
|
|
369
|
+
const result = await reattachConversation(baseUrl, projectId, instanceToken, fingerprint, envTargetConversationId);
|
|
370
|
+
log(`[viber-channel] startup: explicit-target reattach OK, conv_id=${result.conversation_id}\n`);
|
|
371
|
+
try {
|
|
372
|
+
writeHandle(sessionPath, result.conversation_id);
|
|
373
|
+
} catch (writeErr) {
|
|
374
|
+
log(`[viber-channel] Warning: failed to write session handle after explicit-target reattach: ${String(writeErr)}\n`);
|
|
375
|
+
}
|
|
376
|
+
return result;
|
|
377
|
+
} catch (err) {
|
|
378
|
+
// 401 (token revoked) → let the outer handler re-register and retry.
|
|
379
|
+
if (err instanceof ConversationMintError && err.status === 401) throw err;
|
|
380
|
+
let reason: string;
|
|
381
|
+
if (err instanceof ReattachFailedError) {
|
|
382
|
+
reason =
|
|
383
|
+
err.code === "conversation_in_use"
|
|
384
|
+
? "is already in use by a live agent"
|
|
385
|
+
: err.code === "reattach_revoked" || err.code === "reattach_not_found"
|
|
386
|
+
? "was revoked or no longer exists"
|
|
387
|
+
: `could not be reattached (${err.code})`;
|
|
388
|
+
} else if (err instanceof ConversationMintError && err.status >= 500) {
|
|
389
|
+
reason = "could not be verified — the server was unavailable (try again)";
|
|
390
|
+
} else {
|
|
391
|
+
reason = `could not be loaded (${String(err)})`;
|
|
392
|
+
}
|
|
393
|
+
// HARD FAIL — no mint, no heuristic fallback. The channel catches this and
|
|
394
|
+
// exits cleanly with a clear message (never a fresh conversation).
|
|
395
|
+
throw new ExplicitTargetUnavailableError(envTargetConversationId, reason);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
273
399
|
const handle = readHandle(sessionPath);
|
|
274
400
|
|
|
275
401
|
// Branch on `handle` directly so TS narrows it to non-null inside — readHandle
|
|
@@ -292,10 +418,13 @@ export async function acquireConversation(
|
|
|
292
418
|
} catch (err) {
|
|
293
419
|
// Reattach failure is NEVER fatal. Everything (ReattachFailedError 409,
|
|
294
420
|
// unexpected 5xx, network errors from a thrown fetch) falls through to
|
|
295
|
-
// mint below
|
|
421
|
+
// the resume-by-name lookup then mint below (which surfaces a 401 to the
|
|
422
|
+
// caller if the token is dead). A 409 (handle points at a dead/revoked
|
|
423
|
+
// conv) is the realistic case: resume-by-name then finds the LIVE conv of
|
|
424
|
+
// the same name instead of minting.
|
|
296
425
|
const reason = err instanceof ReattachFailedError ? err.code : String(err);
|
|
297
|
-
log(`[viber-channel] startup: reattach failed (${reason}), falling back to mint\n`);
|
|
298
|
-
// Fall through to mint below
|
|
426
|
+
log(`[viber-channel] startup: reattach failed (${reason}), falling back to resume-by-name / mint\n`);
|
|
427
|
+
// Fall through to resume-by-name / mint below
|
|
299
428
|
}
|
|
300
429
|
} else {
|
|
301
430
|
log(`[viber-channel] startup: session handle stale (conv_id=${handle.conversation_id}, age=${handleAge}s >= ${reattachWindowSeconds}s window), minting fresh\n`);
|
|
@@ -304,8 +433,72 @@ export async function acquireConversation(
|
|
|
304
433
|
log(`[viber-channel] startup: no session handle found, minting fresh\n`);
|
|
305
434
|
}
|
|
306
435
|
|
|
307
|
-
//
|
|
308
|
-
|
|
436
|
+
// #400 resume-by-name (handle-absent path): a named agent re-registers a fresh
|
|
437
|
+
// instance_id each start, so on a new machine / cleared tmp / MCP reconnect the
|
|
438
|
+
// handle above is missing. Ask the server which channel-attached conversation
|
|
439
|
+
// carries this name; a UNIQUE match is reattached (reuse instead of mint), a
|
|
440
|
+
// collision (≥2) or no match falls through to mint — never guess. Lookup
|
|
441
|
+
// failure is non-fatal → mint.
|
|
442
|
+
if (resumeLabel !== null) {
|
|
443
|
+
try {
|
|
444
|
+
const candidates = await resumeCandidatesByLabel(baseUrl, instanceToken, fingerprint, resumeLabel);
|
|
445
|
+
if (candidates.length === 1) {
|
|
446
|
+
const targetId = candidates[0].conversation_id;
|
|
447
|
+
log(`[viber-channel] startup: resume-by-name "${resumeLabel}" → unique match ${targetId}, attempting reattach\n`);
|
|
448
|
+
try {
|
|
449
|
+
const result = await reattachConversation(baseUrl, projectId, instanceToken, fingerprint, targetId);
|
|
450
|
+
log(`[viber-channel] startup: resume-by-name reattach OK, conv_id=${result.conversation_id}\n`);
|
|
451
|
+
try {
|
|
452
|
+
writeHandle(sessionPath, result.conversation_id);
|
|
453
|
+
} catch (writeErr) {
|
|
454
|
+
log(`[viber-channel] Warning: failed to write session handle after resume-by-name reattach: ${String(writeErr)}\n`);
|
|
455
|
+
}
|
|
456
|
+
return result;
|
|
457
|
+
} catch (err) {
|
|
458
|
+
const reason = err instanceof ReattachFailedError ? err.code : String(err);
|
|
459
|
+
log(`[viber-channel] startup: resume-by-name reattach failed (${reason}), minting fresh\n`);
|
|
460
|
+
}
|
|
461
|
+
} else if (candidates.length > 1) {
|
|
462
|
+
const ids = candidates.map((c) => c.conversation_id).join(", ");
|
|
463
|
+
log(`[viber-channel] startup: resume-by-name "${resumeLabel}" → label collision (${candidates.length} candidates: ${ids}), minting fresh\n`);
|
|
464
|
+
} else {
|
|
465
|
+
log(`[viber-channel] startup: resume-by-name "${resumeLabel}" → no match, minting fresh\n`);
|
|
466
|
+
}
|
|
467
|
+
} catch (err) {
|
|
468
|
+
log(`[viber-channel] startup: resume-by-name lookup failed (${String(err)}), minting fresh\n`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Mint a new conversation.
|
|
473
|
+
//
|
|
474
|
+
// #400 sticky-target safety: target_conversation_id in auth.json is STICKY
|
|
475
|
+
// (written once by the reconnect/attach flow, never cleared — connect.ts). When
|
|
476
|
+
// that target is later REVOKED (e.g. by the #400 batch cleanup, or unit
|
|
477
|
+
// revocation), the server routes target → handleReattach → 409 reattach_revoked
|
|
478
|
+
// / reattach_not_found, and mintConversation throws ConversationMintError. Since
|
|
479
|
+
// viber-channel.ts only catches 401 (re-register), an un-tolerated 409 here
|
|
480
|
+
// would CRASH startup — and the two #400 features (reconnect + cleanup) combine
|
|
481
|
+
// to make it reachable. So: on a 409 from the TARGET path, fall back to a fresh
|
|
482
|
+
// mint WITHOUT the (dead) target. A 401 still propagates (token revoked →
|
|
483
|
+
// caller re-registers); other errors still propagate.
|
|
484
|
+
let result: ConversationMintResponse;
|
|
485
|
+
try {
|
|
486
|
+
result = await mintConversation(baseUrl, projectId, instanceToken, fingerprint, label, targetConversationId ?? null);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
if (
|
|
489
|
+
targetConversationId != null &&
|
|
490
|
+
err instanceof ConversationMintError &&
|
|
491
|
+
(err.status === 409 || err.status >= 500)
|
|
492
|
+
) {
|
|
493
|
+
// 409 = dead/revoked/in-use target; 5xx = transient server/lease fault. The
|
|
494
|
+
// auth.json target is a STICKY folder-level hint (not an explicit operator
|
|
495
|
+
// request), so a graceful fresh mint is correct — never crash on it (Opus).
|
|
496
|
+
log(`[viber-channel] startup: sticky target ${targetConversationId} not reattachable (${err.message}), minting fresh without target\n`);
|
|
497
|
+
result = await mintConversation(baseUrl, projectId, instanceToken, fingerprint, label, null);
|
|
498
|
+
} else {
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
309
502
|
log(`[viber-channel] startup: mint OK, conv_id=${result.conversation_id}\n`);
|
|
310
503
|
try {
|
|
311
504
|
writeHandle(sessionPath, result.conversation_id);
|
package/lib/heartbeat.ts
CHANGED
|
@@ -13,10 +13,18 @@
|
|
|
13
13
|
* was removed in #315 once "Active" derived from instance liveness.)
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { sendInstanceHeartbeat } from "./control_stream.ts";
|
|
16
|
+
import { type HeartbeatResult, sendInstanceHeartbeat } from "./control_stream.ts";
|
|
17
17
|
|
|
18
18
|
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000;
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* #400 (recadrage) — consecutive INDETERMINATE lease beats tolerated before the
|
|
22
|
+
* channel closes. A definite `lease_active:false` closes immediately; `null`
|
|
23
|
+
* (D1 error / no body) is tolerated up to this many beats so a transient fault
|
|
24
|
+
* doesn't kill a live agent, then we close (the margin Codex asked for).
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_MAX_INDETERMINATE_LEASE_BEATS = 3;
|
|
27
|
+
|
|
20
28
|
/**
|
|
21
29
|
* Resolve the beat interval from the environment (seconds), defaulting to 25s.
|
|
22
30
|
* Must stay comfortably under the server timeout (VIBER_CHANNEL_PRESENCE_TIMEOUT,
|
|
@@ -44,8 +52,26 @@ export interface InstanceHeartbeatOptions {
|
|
|
44
52
|
log?: (line: string) => void;
|
|
45
53
|
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
46
54
|
clearTimer?: (handle: unknown) => void;
|
|
55
|
+
/**
|
|
56
|
+
* #400 — the conversation this agent is attached to, read each tick so the beat
|
|
57
|
+
* renews its lease. null (plain channel / not yet bound) → no lease renewal.
|
|
58
|
+
*/
|
|
59
|
+
getConversationId?: () => string | null;
|
|
60
|
+
/**
|
|
61
|
+
* #400 — called when the agent has DEFINITELY lost its conversation lease
|
|
62
|
+
* (a new holder took over, or too many consecutive indeterminate beats). The
|
|
63
|
+
* channel closes to avoid two live agents on one conversation.
|
|
64
|
+
*/
|
|
65
|
+
onLeaseLost?: () => void;
|
|
66
|
+
/** Consecutive indeterminate beats tolerated before closing (default 3). */
|
|
67
|
+
maxIndeterminateLeaseBeats?: number;
|
|
47
68
|
/** Injectable beat fn for tests. Defaults to sendInstanceHeartbeat. */
|
|
48
|
-
beat?: (
|
|
69
|
+
beat?: (
|
|
70
|
+
baseUrl: string,
|
|
71
|
+
instanceId: string,
|
|
72
|
+
instanceToken: string,
|
|
73
|
+
conversationId: string | null,
|
|
74
|
+
) => Promise<HeartbeatResult>;
|
|
49
75
|
}
|
|
50
76
|
|
|
51
77
|
/**
|
|
@@ -64,15 +90,41 @@ export function startInstanceHeartbeat(opts: InstanceHeartbeatOptions): Heartbea
|
|
|
64
90
|
opts.clearTimer ??
|
|
65
91
|
((h: unknown) => clearInterval(h as ReturnType<typeof setInterval>));
|
|
66
92
|
const beat = opts.beat ?? sendInstanceHeartbeat;
|
|
93
|
+
const getConversationId = opts.getConversationId ?? (() => null);
|
|
94
|
+
const maxIndeterminate =
|
|
95
|
+
opts.maxIndeterminateLeaseBeats ?? DEFAULT_MAX_INDETERMINATE_LEASE_BEATS;
|
|
67
96
|
|
|
68
97
|
let inFlight = false;
|
|
98
|
+
let indeterminateStreak = 0;
|
|
99
|
+
let leaseLostFired = false;
|
|
100
|
+
const fireLeaseLost = (why: string) => {
|
|
101
|
+
if (leaseLostFired) return;
|
|
102
|
+
leaseLostFired = true;
|
|
103
|
+
log(`[viber-channel] conversation lease lost (${why}) — closing channel\n`);
|
|
104
|
+
opts.onLeaseLost?.();
|
|
105
|
+
};
|
|
106
|
+
|
|
69
107
|
const handle = setTimer(() => {
|
|
70
108
|
if (inFlight) return;
|
|
71
109
|
inFlight = true;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
110
|
+
const conversationId = getConversationId();
|
|
111
|
+
void beat(opts.baseUrl, opts.instanceId, opts.getInstanceToken(), conversationId)
|
|
112
|
+
.then((result) => {
|
|
113
|
+
if (!result.ok) {
|
|
75
114
|
log("[viber-channel] instance heartbeat failed (will retry next interval)\n");
|
|
115
|
+
return; // a failed beat never reached the server — not a lease signal
|
|
116
|
+
}
|
|
117
|
+
// #400 lease state — only meaningful when we asked about a conversation.
|
|
118
|
+
if (conversationId === null) return;
|
|
119
|
+
if (result.leaseActive === false) {
|
|
120
|
+
fireLeaseLost("a new holder took over");
|
|
121
|
+
} else if (result.leaseActive === null) {
|
|
122
|
+
indeterminateStreak += 1;
|
|
123
|
+
if (indeterminateStreak >= maxIndeterminate) {
|
|
124
|
+
fireLeaseLost(`${indeterminateStreak} consecutive indeterminate beats`);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
indeterminateStreak = 0; // true → still ours, reset the margin
|
|
76
128
|
}
|
|
77
129
|
})
|
|
78
130
|
.finally(() => {
|
package/lib/instance.ts
CHANGED
|
@@ -76,6 +76,77 @@ export function instanceKindFromEnv(): string | undefined {
|
|
|
76
76
|
return raw !== undefined && raw.trim() !== "" ? raw.trim() : undefined;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The DB-backed team this process should join (#398), from `VIBER_INSTANCE_TEAM`
|
|
81
|
+
* (set by the spawn path — vibe-master's agent_spec). undefined when unset → the
|
|
82
|
+
* agent joins no team. Mirrors `instanceKindFromEnv`.
|
|
83
|
+
*/
|
|
84
|
+
export function instanceTeamFromEnv(): string | undefined {
|
|
85
|
+
const raw = process.env.VIBER_INSTANCE_TEAM;
|
|
86
|
+
return raw !== undefined && raw.trim() !== "" ? raw.trim() : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* POST /api/projects/:id/teams/members — attach this instance to a team by name
|
|
91
|
+
* (#398). A SEPARATE authorized call AFTER register (the register payload never
|
|
92
|
+
* carries a client-declared team — same discipline as the orchestrator flag).
|
|
93
|
+
* Team is project-scoped metadata; the worker upserts it and verifies the
|
|
94
|
+
* instance belongs to the token's project. Throws InstanceRegisterError on !ok.
|
|
95
|
+
*/
|
|
96
|
+
export async function attachInstanceTeam(
|
|
97
|
+
baseUrl: string,
|
|
98
|
+
projectId: number,
|
|
99
|
+
projectToken: string,
|
|
100
|
+
fingerprint: string,
|
|
101
|
+
instanceId: string,
|
|
102
|
+
team: string,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
const resp = await fetch(
|
|
105
|
+
`${baseUrl}/api/projects/${projectId}/teams/members`,
|
|
106
|
+
{
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: {
|
|
109
|
+
Authorization: `Bearer ${projectToken}`,
|
|
110
|
+
"X-Client-Fingerprint": fingerprint,
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
...cfAccessHeaders(),
|
|
113
|
+
},
|
|
114
|
+
body: JSON.stringify({ team, instance_id: instanceId }),
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
if (!resp.ok) {
|
|
118
|
+
const detail = await resp.text().catch(() => "");
|
|
119
|
+
throw new InstanceRegisterError(resp.status, detail);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Best-effort self-attach to `VIBER_INSTANCE_TEAM`, run AFTER a successful
|
|
125
|
+
* register/acquire (#398, R-CLAUDE: called from BOTH acquireInstance — claude/
|
|
126
|
+
* plain — and acquireBridgeInstance — codex/gemma). No-op when no team is
|
|
127
|
+
* requested or no server instance_id is known. A failure (legacy worker 404,
|
|
128
|
+
* validation, network) is logged and swallowed — the agent still works,
|
|
129
|
+
* un-teamed (forward-compatible). NOT retried.
|
|
130
|
+
*/
|
|
131
|
+
export async function maybeAttachTeam(
|
|
132
|
+
baseUrl: string,
|
|
133
|
+
projectId: number,
|
|
134
|
+
projectToken: string,
|
|
135
|
+
fingerprint: string,
|
|
136
|
+
instanceId: string | undefined,
|
|
137
|
+
log: (msg: string) => void = (m) => process.stderr.write(m),
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const team = instanceTeamFromEnv();
|
|
140
|
+
if (team === undefined || instanceId === undefined) return;
|
|
141
|
+
try {
|
|
142
|
+
await attachInstanceTeam(baseUrl, projectId, projectToken, fingerprint, instanceId, team);
|
|
143
|
+
log(`[viber-channel] team: attached instance ${instanceId.slice(0, 8)} to team "${team}"\n`);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
146
|
+
log(`[viber-channel] team: self-attach to "${team}" failed (non-fatal): ${msg}\n`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
79
150
|
export interface AcquiredInstance {
|
|
80
151
|
instance_token: string;
|
|
81
152
|
/**
|
|
@@ -116,6 +187,10 @@ export async function acquireInstance(
|
|
|
116
187
|
log: (msg: string) => void = (m) => process.stderr.write(m),
|
|
117
188
|
cwd: string = process.cwd(),
|
|
118
189
|
): Promise<AcquiredInstance> {
|
|
190
|
+
// Resolve the identity into `result` (one of the four paths), then run the
|
|
191
|
+
// #398 team self-attach ONCE at the single exit — covers every path (incl. the
|
|
192
|
+
// claude/plain register paths) without scattering the call (R-CLAUDE).
|
|
193
|
+
let result: AcquiredInstance;
|
|
119
194
|
const envToken = process.env.VIBER_INSTANCE_TOKEN;
|
|
120
195
|
if (envToken !== undefined && envToken.trim() !== "") {
|
|
121
196
|
const token = envToken.trim();
|
|
@@ -123,42 +198,44 @@ export async function acquireInstance(
|
|
|
123
198
|
const realId = envId !== undefined && envId.trim() !== "" ? envId.trim() : undefined;
|
|
124
199
|
const key = realId ?? token;
|
|
125
200
|
log(`[viber-channel] instance: using VIBER_INSTANCE_TOKEN (env-injected)\n`);
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
201
|
+
result = { instance_token: token, instance_key: key, instance_id: realId };
|
|
202
|
+
} else {
|
|
203
|
+
const envLabel = process.env.VIBER_CHANNEL_LABEL;
|
|
204
|
+
if (envLabel !== undefined && envLabel.trim() !== "") {
|
|
205
|
+
const label = envLabel.trim();
|
|
206
|
+
log(`[viber-channel] instance: registering fresh labelled instance "${label}"\n`);
|
|
207
|
+
const reg = await registerInstance(
|
|
208
|
+
baseUrl,
|
|
209
|
+
auth.project_id,
|
|
210
|
+
auth.project_token,
|
|
211
|
+
fingerprint,
|
|
212
|
+
label,
|
|
213
|
+
instanceKindFromEnv(),
|
|
214
|
+
);
|
|
215
|
+
result = { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
|
|
216
|
+
} else if (auth.instance_token !== undefined && auth.instance_token.trim() !== "") {
|
|
217
|
+
const realId =
|
|
218
|
+
auth.instance_id !== undefined && auth.instance_id.trim() !== "" ? auth.instance_id : undefined;
|
|
219
|
+
const key = realId ?? auth.instance_token;
|
|
220
|
+
log(`[viber-channel] instance: reusing persisted instance_token (id=${auth.instance_id ?? "?"})\n`);
|
|
221
|
+
result = { instance_token: auth.instance_token, instance_key: key, instance_id: realId };
|
|
222
|
+
} else {
|
|
223
|
+
log(`[viber-channel] instance: none found — registering a new one via the project_token\n`);
|
|
224
|
+
const reg = await registerInstance(
|
|
225
|
+
baseUrl,
|
|
226
|
+
auth.project_id,
|
|
227
|
+
auth.project_token,
|
|
228
|
+
fingerprint,
|
|
229
|
+
undefined,
|
|
230
|
+
instanceKindFromEnv(),
|
|
231
|
+
);
|
|
232
|
+
persistInstanceCredentials(authFilePath(cwd), reg.instance_id, reg.instance_token, log);
|
|
233
|
+
log(`[viber-channel] instance: registered + persisted (id=${reg.instance_id})\n`);
|
|
234
|
+
result = { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
|
|
235
|
+
}
|
|
150
236
|
}
|
|
151
237
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
auth.project_id,
|
|
156
|
-
auth.project_token,
|
|
157
|
-
fingerprint,
|
|
158
|
-
undefined,
|
|
159
|
-
instanceKindFromEnv(),
|
|
160
|
-
);
|
|
161
|
-
persistInstanceCredentials(authFilePath(cwd), reg.instance_id, reg.instance_token, log);
|
|
162
|
-
log(`[viber-channel] instance: registered + persisted (id=${reg.instance_id})\n`);
|
|
163
|
-
return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
|
|
238
|
+
// #398: best-effort self-attach to VIBER_INSTANCE_TEAM (no-op when unset).
|
|
239
|
+
await maybeAttachTeam(baseUrl, auth.project_id, auth.project_token, fingerprint, result.instance_id, log);
|
|
240
|
+
return result;
|
|
164
241
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viber-channel",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
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
|
@@ -31,18 +31,18 @@ import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { runConnect } from "./lib/connect.ts";
|
|
33
33
|
import { lockFilePath, lockSessionId } from "./lib/lockfile.ts";
|
|
34
|
-
import {
|
|
34
|
+
import { resolveSessionFilePath, namedAgentLabel, clearHandle } from "./lib/channel_session.ts";
|
|
35
35
|
import { clientFingerprint } from "./lib/fingerprint.ts";
|
|
36
36
|
import { MessageDedup } from "./lib/message_dedup.ts";
|
|
37
37
|
import {
|
|
38
38
|
acquireConversation,
|
|
39
39
|
ConversationMintError,
|
|
40
|
-
|
|
40
|
+
ExplicitTargetUnavailableError,
|
|
41
41
|
type ConversationMintResponse,
|
|
42
42
|
} from "./lib/conversation.ts";
|
|
43
43
|
import { startInstanceHeartbeat } from "./lib/heartbeat.ts";
|
|
44
44
|
import { startParentWatchdog } from "./lib/parent_watchdog.ts";
|
|
45
|
-
import { acquireInstance, registerInstance } from "./lib/instance.ts";
|
|
45
|
+
import { acquireInstance, maybeAttachTeam, registerInstance } from "./lib/instance.ts";
|
|
46
46
|
import {
|
|
47
47
|
runPersistentControlStream,
|
|
48
48
|
sendInstanceHeartbeat,
|
|
@@ -71,7 +71,17 @@ import { capabilitiesText } from "./lib/capabilities.ts";
|
|
|
71
71
|
// loads in the same session with the same env; instead of racing it for the
|
|
72
72
|
// channel lock, it bows out deterministically here. Normal sessions
|
|
73
73
|
// (no VIBER_CHANNEL_AGENT_SESSION) are unaffected.
|
|
74
|
+
//
|
|
75
|
+
// BUT the yield is a STAGING/PROD-only behavior: the plugin channel only runs
|
|
76
|
+
// there (it is disabled in the Viber dev repo, #365). On the DEV backend there
|
|
77
|
+
// is NO plugin to yield to, so yielding = no channel at all (`-32000`, no
|
|
78
|
+
// registration). Derive this from the environment (single source of truth,
|
|
79
|
+
// isDevBackend) instead of relying on VIBER_CHANNEL_AGENT_SESSION not leaking
|
|
80
|
+
// from the parent shell into the spawned dev terminal (the real bug: that var
|
|
81
|
+
// leaked and terminal.ts never cleared it, so the dev channel silently bowed
|
|
82
|
+
// out). Default (non-dev) behavior is unchanged.
|
|
74
83
|
if (
|
|
84
|
+
!isDevBackend() &&
|
|
75
85
|
process.env.VIBER_CHANNEL_AGENT_SESSION === "1" &&
|
|
76
86
|
process.env.VIBER_CHANNEL_PLUGIN !== "1"
|
|
77
87
|
) {
|
|
@@ -159,22 +169,28 @@ const LOCK_FILE = lockFilePath(LOCK_BASE_URL, LOCK_FINGERPRINT, LOCK_SESSION_ID,
|
|
|
159
169
|
// acquired (#269) — it is namespaced by the server-issued instance, not the
|
|
160
170
|
// local fingerprint/sessionId. Declared as a `let` near the mint below.
|
|
161
171
|
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
172
|
+
// Reattach window (#400): NEUTRALISED BY DEFAULT. The server `handleReattach`
|
|
173
|
+
// is the contract of truth — it reattaches any non-revoked channel conversation
|
|
174
|
+
// of the right owner/project REGARDLESS OF AGE (only the token expires, the row
|
|
175
|
+
// persists). The old 1h default (DEFAULT_REATTACH_WINDOW_SECONDS) was a purely
|
|
176
|
+
// client-side guard whose "stale handle points at an expired conversation"
|
|
177
|
+
// rationale is FALSE per the server code, and it was the reason a channel minted
|
|
178
|
+
// a fresh conversation after 1h instead of reattaching. Default is now
|
|
179
|
+
// unbounded (Infinity) → always attempt reattach when a handle exists; the
|
|
180
|
+
// server rejects (409) if the conversation is genuinely gone → fall back to mint.
|
|
181
|
+
// VIBER_REATTACH_WINDOW_SECONDS still caps the window if a finite value is set.
|
|
166
182
|
const REATTACH_WINDOW_SECONDS: number = (() => {
|
|
167
183
|
const raw = process.env.VIBER_REATTACH_WINDOW_SECONDS;
|
|
168
|
-
if (raw === undefined) return
|
|
184
|
+
if (raw === undefined) return Number.POSITIVE_INFINITY;
|
|
169
185
|
const parsed = Number.parseInt(raw, 10);
|
|
170
186
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
171
187
|
process.stderr.write(
|
|
172
|
-
`[viber-channel] Invalid VIBER_REATTACH_WINDOW_SECONDS=${raw}, ignoring.\n`
|
|
188
|
+
`[viber-channel] Invalid VIBER_REATTACH_WINDOW_SECONDS=${raw}, ignoring (window unbounded).\n`
|
|
173
189
|
);
|
|
174
|
-
return
|
|
190
|
+
return Number.POSITIVE_INFINITY;
|
|
175
191
|
}
|
|
176
192
|
process.stderr.write(
|
|
177
|
-
`[viber-channel] reattach window
|
|
193
|
+
`[viber-channel] reattach window capped to ${parsed}s via VIBER_REATTACH_WINDOW_SECONDS\n`
|
|
178
194
|
);
|
|
179
195
|
return parsed;
|
|
180
196
|
})();
|
|
@@ -307,7 +323,12 @@ process.stdin.on("end", () => {
|
|
|
307
323
|
|
|
308
324
|
// ---- Auth from .viber/auth.json ----
|
|
309
325
|
|
|
310
|
-
import {
|
|
326
|
+
import {
|
|
327
|
+
authFilePath,
|
|
328
|
+
isDevBackend,
|
|
329
|
+
loadAuth,
|
|
330
|
+
persistInstanceCredentials,
|
|
331
|
+
} from "./lib/auth.ts";
|
|
311
332
|
import { handleConversationTokenExpired } from "./lib/channel_errors.ts";
|
|
312
333
|
import { buildSseUrl } from "./lib/urls.ts";
|
|
313
334
|
|
|
@@ -644,14 +665,60 @@ try {
|
|
|
644
665
|
// token that instance_key may fall back to (Codex review P1). Undefined → ""
|
|
645
666
|
// → the guard is a no-op (delivers everything), which is the safe default.
|
|
646
667
|
OWN_INSTANCE_ID = acquired.instance_id ?? "";
|
|
647
|
-
SESSION_FILE = sessionFilePath(LOCK_BASE_URL, acquired.instance_key, LOCK_DIR);
|
|
648
668
|
|
|
649
|
-
|
|
669
|
+
// #400: a NAMED agent (VIBER_CHANNEL_LABEL, no env-injected VIBER_INSTANCE_TOKEN)
|
|
670
|
+
// re-registers a FRESH instance_id on every start (acquireInstance path 2), so
|
|
671
|
+
// namespacing the handle by instance_id never finds it on the next start → mint
|
|
672
|
+
// loop. resolveSessionFilePath keys such an agent by the STABLE NAME (project-
|
|
673
|
+
// scoped) so a re-spawn / MCP reconnect reattaches its previous conversation;
|
|
674
|
+
// the plain channel (or a path-1 injected token) keeps the instance_id key —
|
|
675
|
+
// #269 isolation. Selection logic lives in the pure helper (unit-tested).
|
|
676
|
+
const computeSessionFile = (instanceKey: string): string =>
|
|
677
|
+
resolveSessionFilePath({
|
|
678
|
+
baseUrl: LOCK_BASE_URL,
|
|
679
|
+
projectId: auth.project_id,
|
|
680
|
+
channelLabel: process.env.VIBER_CHANNEL_LABEL,
|
|
681
|
+
instanceToken: process.env.VIBER_INSTANCE_TOKEN,
|
|
682
|
+
instanceKey,
|
|
683
|
+
dir: LOCK_DIR,
|
|
684
|
+
});
|
|
685
|
+
SESSION_FILE = computeSessionFile(acquired.instance_key);
|
|
686
|
+
|
|
687
|
+
// #400 resume-by-name lookup key: EXACTLY the label the mint writes into
|
|
688
|
+
// conversations.label, i.e. VIBER_CONVERSATION_LABEL (see `label` above:
|
|
689
|
+
// `VIBER_CONVERSATION_LABEL ?? defaultLabel(cwd)`). Only meaningful when it is
|
|
690
|
+
// explicitly set AND this is a named agent — otherwise the mint uses the
|
|
691
|
+
// timestamped defaultLabel, which is NOT resumable, so the lookup is disabled
|
|
692
|
+
// (null). No VIBER_CHANNEL_LABEL fallback: that would query a value the mint
|
|
693
|
+
// never wrote (Opus review B — query exactly what the mint writes).
|
|
694
|
+
const namedSession = namedAgentLabel(process.env.VIBER_CHANNEL_LABEL, process.env.VIBER_INSTANCE_TOKEN) !== null;
|
|
695
|
+
const explicitConvLabel = process.env.VIBER_CONVERSATION_LABEL?.trim();
|
|
696
|
+
const RESUME_LABEL: string | null = namedSession && explicitConvLabel ? explicitConvLabel : null;
|
|
697
|
+
|
|
698
|
+
// #400 (recadrage) — EXPLICIT per-agent target conversation, injected by
|
|
699
|
+
// vibe-master `spawn --conversation <id>` / the TUI "load" picker. Highest
|
|
700
|
+
// priority in acquireConversation: bind THIS conversation by id (never via the
|
|
701
|
+
// shared folder auth.json). null when not spawned onto a specific conversation.
|
|
702
|
+
const ENV_TARGET_CONVERSATION_ID = process.env.VIBER_TARGET_CONVERSATION_ID?.trim() || null;
|
|
703
|
+
|
|
704
|
+
process.stderr.write(`[viber-channel] startup: acquiring conversation (project=${auth.project_id}, target=${auth.target_conversation_id ?? "null"}, session=${SESSION_FILE}, resume_label=${RESUME_LABEL ?? "none"})\n`);
|
|
650
705
|
|
|
651
706
|
// Join the conversation with the instance_token. A 401 means the instance was
|
|
652
707
|
// revoked (the token is durable — no expiry case): re-register a fresh instance
|
|
653
708
|
// ONCE (a NEW instance_id, the correct post-revocation outcome) and retry. A
|
|
654
709
|
// second failure falls through to the catch below.
|
|
710
|
+
// #400 (recadrage): an explicit `--conversation <id>` that can't be loaded is a
|
|
711
|
+
// CLEAN, non-zero exit with a readable message — never a fresh conversation,
|
|
712
|
+
// never an uncaught stack trace.
|
|
713
|
+
const exitExplicitTargetUnavailable = (err: ExplicitTargetUnavailableError): never => {
|
|
714
|
+
process.stderr.write(
|
|
715
|
+
`[viber-channel] cannot load requested conversation ${err.conversationId}: ${err.reason}. Agent NOT started.\n`,
|
|
716
|
+
);
|
|
717
|
+
scheduler?.cancel();
|
|
718
|
+
releaseLock();
|
|
719
|
+
process.exit(1);
|
|
720
|
+
};
|
|
721
|
+
|
|
655
722
|
let minted: ConversationMintResponse;
|
|
656
723
|
try {
|
|
657
724
|
minted = await acquireConversation(
|
|
@@ -663,8 +730,14 @@ try {
|
|
|
663
730
|
label,
|
|
664
731
|
auth.target_conversation_id ?? null,
|
|
665
732
|
REATTACH_WINDOW_SECONDS,
|
|
733
|
+
undefined, // log — use the default stderr writer
|
|
734
|
+
RESUME_LABEL,
|
|
735
|
+
ENV_TARGET_CONVERSATION_ID,
|
|
666
736
|
);
|
|
667
737
|
} catch (err) {
|
|
738
|
+
if (err instanceof ExplicitTargetUnavailableError) {
|
|
739
|
+
exitExplicitTargetUnavailable(err);
|
|
740
|
+
}
|
|
668
741
|
if (err instanceof ConversationMintError && err.status === 401) {
|
|
669
742
|
process.stderr.write(
|
|
670
743
|
`[viber-channel] instance token rejected (401 revoked) — re-registering a fresh instance once\n`,
|
|
@@ -678,17 +751,32 @@ try {
|
|
|
678
751
|
persistInstanceCredentials(authFilePath(), reg.instance_id, reg.instance_token);
|
|
679
752
|
instanceToken = reg.instance_token;
|
|
680
753
|
OWN_INSTANCE_ID = reg.instance_id;
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
754
|
+
// #398: re-attach the fresh (post-revocation) instance to its team.
|
|
755
|
+
await maybeAttachTeam(BASE_URL, auth.project_id, auth.project_token, auth.client_fingerprint, reg.instance_id);
|
|
756
|
+
// #400: keep the SAME namespacing rule after a post-revocation re-register.
|
|
757
|
+
// A named agent stays keyed by its stable name (not the new instance_id),
|
|
758
|
+
// so its handle survives; the plain channel re-keys to the fresh id.
|
|
759
|
+
SESSION_FILE = computeSessionFile(reg.instance_id);
|
|
760
|
+
try {
|
|
761
|
+
minted = await acquireConversation(
|
|
762
|
+
SESSION_FILE,
|
|
763
|
+
BASE_URL,
|
|
764
|
+
auth.project_id,
|
|
765
|
+
instanceToken,
|
|
766
|
+
auth.client_fingerprint,
|
|
767
|
+
label,
|
|
768
|
+
auth.target_conversation_id ?? null,
|
|
769
|
+
REATTACH_WINDOW_SECONDS,
|
|
770
|
+
undefined, // log — use the default stderr writer
|
|
771
|
+
RESUME_LABEL,
|
|
772
|
+
ENV_TARGET_CONVERSATION_ID,
|
|
773
|
+
);
|
|
774
|
+
} catch (retryErr) {
|
|
775
|
+
if (retryErr instanceof ExplicitTargetUnavailableError) {
|
|
776
|
+
exitExplicitTargetUnavailable(retryErr);
|
|
777
|
+
}
|
|
778
|
+
throw retryErr;
|
|
779
|
+
}
|
|
692
780
|
} else {
|
|
693
781
|
throw err;
|
|
694
782
|
}
|
|
@@ -737,6 +825,16 @@ try {
|
|
|
737
825
|
baseUrl: BASE_URL,
|
|
738
826
|
instanceId: OWN_INSTANCE_ID,
|
|
739
827
|
getInstanceToken: () => INSTANCE_TOKEN,
|
|
828
|
+
// #400 (recadrage): renew this agent's conversation lease each beat. If the
|
|
829
|
+
// server reports the lease was taken over (a new holder), or too many
|
|
830
|
+
// consecutive indeterminate beats, close the channel — never stay a second
|
|
831
|
+
// live agent on a conversation another agent now owns.
|
|
832
|
+
getConversationId: () => CONVERSATION_ID || null,
|
|
833
|
+
onLeaseLost: () => {
|
|
834
|
+
scheduler?.cancel();
|
|
835
|
+
releaseLock();
|
|
836
|
+
process.exit(0);
|
|
837
|
+
},
|
|
740
838
|
});
|
|
741
839
|
// Stop beating once the control stream ends (revoke / 401 / abort) so a
|
|
742
840
|
// doomed instance doesn't keep beating (Opus review).
|
package/viber-codex-bridge.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from "./lib/conversation.ts";
|
|
19
19
|
import { sessionFilePath, writeHandle } from "./lib/channel_session.ts";
|
|
20
20
|
import { clientFingerprint } from "./lib/fingerprint.ts";
|
|
21
|
-
import { acquireInstance, instanceKindFromEnv, registerInstance } from "./lib/instance.ts";
|
|
21
|
+
import { acquireInstance, instanceKindFromEnv, maybeAttachTeam, registerInstance } from "./lib/instance.ts";
|
|
22
22
|
import {
|
|
23
23
|
acquireBridgeLock as coreAcquireBridgeLock,
|
|
24
24
|
agentIdentityFromEnv,
|
|
@@ -820,6 +820,9 @@ async function acquireBridgeInstance(
|
|
|
820
820
|
process.stderr.write("[viber-codex-bridge] instance: registering fresh bridge instance\n");
|
|
821
821
|
const reg = await registerInstance(baseUrl, auth.project_id, auth.project_token, fingerprint, label, instanceKindFromEnv() ?? "codex");
|
|
822
822
|
process.stderr.write(`[viber-codex-bridge] instance: registered fresh bridge instance (id=${reg.instance_id})\n`);
|
|
823
|
+
// #398 (R-CODEX-FRESH): self-attach here too so codex is teamed structurally,
|
|
824
|
+
// not only via the VIBER_INSTANCE_TOKEN branch above (parity with claude).
|
|
825
|
+
await maybeAttachTeam(baseUrl, auth.project_id, auth.project_token, fingerprint, reg.instance_id);
|
|
823
826
|
return { instance_token: reg.instance_token, instance_key: reg.instance_id };
|
|
824
827
|
}
|
|
825
828
|
|
|
@@ -885,6 +888,8 @@ async function acquireTargetConversation(
|
|
|
885
888
|
const reg = await registerInstance(BASE_URL, auth.project_id, auth.project_token, fingerprint, label, instanceKindFromEnv() ?? "codex");
|
|
886
889
|
instanceToken = reg.instance_token;
|
|
887
890
|
instanceKey = reg.instance_id;
|
|
891
|
+
// #398: re-attach the fresh (post-revocation) codex instance to its team.
|
|
892
|
+
await maybeAttachTeam(BASE_URL, auth.project_id, auth.project_token, fingerprint, reg.instance_id);
|
|
888
893
|
// Identity changed → re-key the lock onto the new instance. Acquire the new
|
|
889
894
|
// lock BEFORE releasing the old one: if acquisition throws, the old lock is
|
|
890
895
|
// still held and the outer catch releases exactly it (no dangling reference).
|
|
@@ -1254,10 +1259,20 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
1254
1259
|
logCodexSafety();
|
|
1255
1260
|
process.stderr.write(`${LOG_PREFIX} ready: joined ${res.minted.conversation_id} as instance ${res.instanceKey.slice(0, 8)}\n`);
|
|
1256
1261
|
// #315: no control stream here, so beat the instance liveness directly.
|
|
1262
|
+
// #400 (recadrage): this bridge holds a lease on its ONE joined conversation
|
|
1263
|
+
// (acquired via reattachConversation → handleReattach). RENEW it each beat, or
|
|
1264
|
+
// it expires after the TTL while the agent is alive and the conversation
|
|
1265
|
+
// falsely frees → another agent could load onto it. On a definite loss (a new
|
|
1266
|
+
// holder took over), shut down so we never double-attach.
|
|
1257
1267
|
const ihb = startInstanceHeartbeat({
|
|
1258
1268
|
baseUrl: BASE_URL,
|
|
1259
1269
|
instanceId: res.instanceKey,
|
|
1260
1270
|
getInstanceToken: () => res.instanceToken,
|
|
1271
|
+
getConversationId: () => res.minted.conversation_id,
|
|
1272
|
+
onLeaseLost: () =>
|
|
1273
|
+
shutdown.abort(
|
|
1274
|
+
new BridgeShutdownError("conversation lease lost (taken over)", 0),
|
|
1275
|
+
),
|
|
1261
1276
|
});
|
|
1262
1277
|
shutdown.signal.addEventListener("abort", () => ihb.stop(), { once: true });
|
|
1263
1278
|
try {
|