viber-channel 0.5.3 → 0.7.0
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/README.md +28 -0
- package/lib/agent_tools.ts +201 -0
- package/lib/auth.ts +50 -1
- package/lib/bridge_core.ts +1019 -0
- package/lib/bridge_spawn.ts +122 -0
- package/lib/bridge_tool_host.ts +191 -0
- package/lib/channel_session.ts +20 -17
- package/lib/connect.ts +10 -1
- package/lib/control_stream.ts +351 -0
- package/lib/conversation.ts +23 -9
- package/lib/dm_stream.ts +287 -0
- package/lib/fingerprint.ts +1 -1
- package/lib/heartbeat.ts +95 -0
- package/lib/instance.ts +164 -0
- package/lib/lockfile.ts +36 -0
- package/lib/messages.ts +32 -0
- package/lib/ollama_chat.ts +106 -0
- package/lib/parent_watchdog.ts +276 -0
- package/lib/peers.ts +113 -0
- package/lib/self_echo.ts +17 -0
- package/lib/supervisor.ts +303 -0
- package/lib/supervisor_config.ts +140 -0
- package/package.json +9 -2
- package/viber-channel.ts +376 -71
- package/viber-codex-bridge.ts +1261 -0
- package/viber-codex-supervisor.ts +96 -0
- package/viber-gemma-bridge.ts +169 -0
package/lib/dm_stream.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dm_stream — consume an agent↔agent DM conversation's SSE stream (#288 step-04).
|
|
3
|
+
*
|
|
4
|
+
* When the control stream pushes a `join` (a peer opened a DM with us, step-02),
|
|
5
|
+
* the channel runs ONE of these per DM, IN PARALLEL with the main conversation
|
|
6
|
+
* SSE — coexistence, not takeover (the reviewed step-04 decision): the main
|
|
7
|
+
* conversation keeps working while DMs are surfaced alongside it.
|
|
8
|
+
*
|
|
9
|
+
* This is intentionally narrow: it only forwards `message` events to `onMessage`
|
|
10
|
+
* (the caller maps them to a Claude notification with provenance). `stop` ends
|
|
11
|
+
* the stream; transient drops reconnect with backoff; a 401 ends it (the DM
|
|
12
|
+
* token expired — the peer can re-open the DM, which re-pushes a fresh join).
|
|
13
|
+
*
|
|
14
|
+
* Side-effect-free and runtime-agnostic so it can be unit-tested with a mock
|
|
15
|
+
* fetch — mirrors the shape of control_stream.ts.
|
|
16
|
+
*/
|
|
17
|
+
import { cfAccessHeaders } from "./cfAccess.js";
|
|
18
|
+
import { buildSseUrl } from "./urls.js";
|
|
19
|
+
import { ConversationTokenExpiredError, fetchMessages } from "./messages.js";
|
|
20
|
+
|
|
21
|
+
/** A conversation message as it arrives on the SSE `message` event. */
|
|
22
|
+
export interface DmMessage {
|
|
23
|
+
content: string;
|
|
24
|
+
id?: string;
|
|
25
|
+
source?: string;
|
|
26
|
+
sender_instance_id?: string | null;
|
|
27
|
+
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Signature of the catch-up fetch (mirrors messages.ts `fetchMessages`). */
|
|
31
|
+
export type FetchMessagesFn = (
|
|
32
|
+
baseUrl: string,
|
|
33
|
+
conversationId: string,
|
|
34
|
+
conversationToken: string,
|
|
35
|
+
) => Promise<Array<Record<string, unknown>>>;
|
|
36
|
+
|
|
37
|
+
export interface DmStreamOptions {
|
|
38
|
+
wsUrl: string;
|
|
39
|
+
conversationId: string;
|
|
40
|
+
conversationToken: string;
|
|
41
|
+
onMessage: (msg: DmMessage) => void | Promise<void>;
|
|
42
|
+
log?: (msg: string) => void;
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
/** Backoff before reconnecting after a transient drop. Default 2000ms. */
|
|
45
|
+
reconnectDelayMs?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Cap on CONSECUTIVE connection errors before giving up (#291 step-04). A
|
|
48
|
+
* clean end (normal SSE recycle) resets the counter, so a healthy stream that
|
|
49
|
+
* recycles forever never trips it; only a persistent failure (5xx/network)
|
|
50
|
+
* does. When the cap is hit the stream stops — the peer re-opening the DM
|
|
51
|
+
* re-pushes a fresh join, so it is not a permanent dead end. Default 8.
|
|
52
|
+
*/
|
|
53
|
+
maxConsecutiveErrors?: number;
|
|
54
|
+
/** Injectable for tests; defaults to global fetch. */
|
|
55
|
+
fetchImpl?: typeof fetch;
|
|
56
|
+
/**
|
|
57
|
+
* Injectable catch-up fetch; defaults to messages.ts `fetchMessages`. On each
|
|
58
|
+
* (re)connect the DM SSE has NO replay, so we re-read the message list and
|
|
59
|
+
* forward anything published while we were disconnected (#291 step-03).
|
|
60
|
+
*/
|
|
61
|
+
fetchMessagesImpl?: FetchMessagesFn;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve after `ms`, or early when `signal` aborts. */
|
|
65
|
+
function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
if (signal?.aborted) return resolve();
|
|
68
|
+
const onAbort = () => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
resolve();
|
|
71
|
+
};
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
signal?.removeEventListener("abort", onAbort);
|
|
74
|
+
resolve();
|
|
75
|
+
}, ms);
|
|
76
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Forward one message to `onMessage` exactly once. `handledIds` makes this
|
|
82
|
+
* idempotent across the catch-up fetch AND the live SSE (a message can arrive
|
|
83
|
+
* by both paths around a reconnect) — dedup keys on the message id, coerced to
|
|
84
|
+
* a string so a numeric API id and a string SSE id match (#291 review, Codex).
|
|
85
|
+
*
|
|
86
|
+
* Unlike the codex bridge, dm_stream does NOT prime/seed history: a DM is
|
|
87
|
+
* freshly opened by the peer, so the message that TRIGGERED the join must be
|
|
88
|
+
* delivered, not swallowed (#291 prime-on-join). Own messages are filtered
|
|
89
|
+
* downstream in the onMessage handler (self-echo), so forwarding all unseen
|
|
90
|
+
* messages here is safe.
|
|
91
|
+
*/
|
|
92
|
+
async function forwardMessage(
|
|
93
|
+
opts: DmStreamOptions,
|
|
94
|
+
handledIds: Set<string>,
|
|
95
|
+
msg: DmMessage,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const idStr =
|
|
98
|
+
msg.id !== undefined && msg.id !== null && String(msg.id).length > 0
|
|
99
|
+
? String(msg.id)
|
|
100
|
+
: undefined;
|
|
101
|
+
if (idStr && handledIds.has(idStr)) return; // already forwarded (live or catch-up)
|
|
102
|
+
// Content guard BEFORE consuming the id: a content-less payload must not burn
|
|
103
|
+
// its id in handledIds, or a later (correctly-populated) catch-up of the same
|
|
104
|
+
// id would be suppressed forever (review: Opus #4).
|
|
105
|
+
if (!msg.content) return;
|
|
106
|
+
// Normalize id to a string so downstream meta (message_id) is set consistently
|
|
107
|
+
// regardless of whether it arrived as a number (GET) or string (SSE).
|
|
108
|
+
const normalized: DmMessage = idStr ? { ...msg, id: idStr } : msg;
|
|
109
|
+
// onMessage errors must NOT look like a connection failure (which would
|
|
110
|
+
// trigger an endless fast reconnect) — catch them separately. Mark the id
|
|
111
|
+
// handled ONLY after a successful delivery: at-least-once beats at-most-once
|
|
112
|
+
// for a delivery-reliability fix, so a transient handler failure is retried on
|
|
113
|
+
// the next catch-up rather than silently dropped (review: Codex exactly-once).
|
|
114
|
+
try {
|
|
115
|
+
await opts.onMessage(normalized);
|
|
116
|
+
if (idStr) handledIds.add(idStr);
|
|
117
|
+
} catch (handlerErr) {
|
|
118
|
+
opts.log?.(
|
|
119
|
+
`[dm-stream] onMessage handler error: ${handlerErr instanceof Error ? handlerErr.message : String(handlerErr)}\n`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Catch up on messages published before/while we were (re)connecting. Runs
|
|
126
|
+
* AFTER the SSE subscription is established (resp.ok) but BEFORE reading live
|
|
127
|
+
* events, mirroring the codex bridge: the subscription is registered server-side
|
|
128
|
+
* by the time the response resolves, so any message published between this fetch
|
|
129
|
+
* and the first live read is still delivered on the stream — `handledIds` dedups
|
|
130
|
+
* the overlap. A 401 here means the DM token expired → surface as a terminal
|
|
131
|
+
* DmStreamAuthError (same as the SSE 401). Other fetch failures are logged and
|
|
132
|
+
* swallowed (the live stream may still deliver).
|
|
133
|
+
*/
|
|
134
|
+
async function catchUp(opts: DmStreamOptions, handledIds: Set<string>): Promise<void> {
|
|
135
|
+
const doFetch = opts.fetchMessagesImpl ?? fetchMessages;
|
|
136
|
+
let msgs: Array<Record<string, unknown>>;
|
|
137
|
+
try {
|
|
138
|
+
msgs = await doFetch(opts.wsUrl, opts.conversationId, opts.conversationToken);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err instanceof ConversationTokenExpiredError) throw new DmStreamAuthError();
|
|
141
|
+
opts.log?.(
|
|
142
|
+
`[dm-stream] ${opts.conversationId} catch-up fetch failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
143
|
+
);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const raw of msgs) {
|
|
147
|
+
// Abort is terminal: this DM session ends here. Any ids already added to
|
|
148
|
+
// handledIds are intentionally dropped — a future DM uses a fresh
|
|
149
|
+
// runDmStream with its own handledIds (review: Opus #2).
|
|
150
|
+
if (opts.signal?.aborted) return;
|
|
151
|
+
await forwardMessage(opts, handledIds, raw as unknown as DmMessage);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One SSE connection attempt: yields each parsed `message` payload until the
|
|
157
|
+
* stream ends, `stop` arrives, or the signal aborts. Returns "stop" if the
|
|
158
|
+
* server signalled stop, "end" on a clean close (caller reconnects), and throws
|
|
159
|
+
* on 401 (token expired) or connection failure. `handledIds` is shared across
|
|
160
|
+
* reconnects so catch-up and live delivery stay idempotent.
|
|
161
|
+
*/
|
|
162
|
+
async function consumeOnce(
|
|
163
|
+
opts: DmStreamOptions,
|
|
164
|
+
handledIds: Set<string>,
|
|
165
|
+
): Promise<"stop" | "end"> {
|
|
166
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
167
|
+
const url = buildSseUrl(opts.wsUrl, opts.conversationId);
|
|
168
|
+
const resp = await doFetch(url, {
|
|
169
|
+
headers: {
|
|
170
|
+
Authorization: `Bearer ${opts.conversationToken}`,
|
|
171
|
+
Accept: "text/event-stream",
|
|
172
|
+
"Cache-Control": "no-cache",
|
|
173
|
+
...cfAccessHeaders(),
|
|
174
|
+
},
|
|
175
|
+
signal: opts.signal,
|
|
176
|
+
});
|
|
177
|
+
if (resp.status === 401) throw new DmStreamAuthError();
|
|
178
|
+
if (!resp.ok || !resp.body) {
|
|
179
|
+
throw new Error(`DM stream connection failed: HTTP ${resp.status}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Subscription is live → replay anything missed before reading live events.
|
|
183
|
+
await catchUp(opts, handledIds);
|
|
184
|
+
|
|
185
|
+
const reader = resp.body.getReader();
|
|
186
|
+
const decoder = new TextDecoder();
|
|
187
|
+
let buffer = "";
|
|
188
|
+
try {
|
|
189
|
+
while (true) {
|
|
190
|
+
const { done, value } = await reader.read();
|
|
191
|
+
if (done) return "end";
|
|
192
|
+
buffer += decoder
|
|
193
|
+
.decode(value, { stream: true })
|
|
194
|
+
.replace(/\r\n/g, "\n")
|
|
195
|
+
.replace(/\r/g, "\n");
|
|
196
|
+
|
|
197
|
+
while (true) {
|
|
198
|
+
const eventEnd = buffer.indexOf("\n\n");
|
|
199
|
+
if (eventEnd === -1) break;
|
|
200
|
+
const block = buffer.slice(0, eventEnd);
|
|
201
|
+
buffer = buffer.slice(eventEnd + 2);
|
|
202
|
+
if (block.startsWith(":")) continue; // keepalive comment
|
|
203
|
+
|
|
204
|
+
let event = "";
|
|
205
|
+
const dataLines: string[] = [];
|
|
206
|
+
for (const line of block.split("\n")) {
|
|
207
|
+
if (line.startsWith(":")) continue; // per-line comment / keepalive
|
|
208
|
+
if (line.startsWith("event:")) event = line.slice(6).trimStart();
|
|
209
|
+
// SSE allows multiple `data:` lines per event → join with "\n".
|
|
210
|
+
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
|
|
211
|
+
}
|
|
212
|
+
if (event === "stop") return "stop";
|
|
213
|
+
const data = dataLines.join("\n");
|
|
214
|
+
if (event !== "message" || !data) continue;
|
|
215
|
+
let msg: DmMessage | null = null;
|
|
216
|
+
try {
|
|
217
|
+
msg = JSON.parse(data) as DmMessage;
|
|
218
|
+
} catch {
|
|
219
|
+
opts.log?.(`[dm-stream] ignored malformed message payload\n`);
|
|
220
|
+
}
|
|
221
|
+
if (msg) await forwardMessage(opts, handledIds, msg);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
try {
|
|
226
|
+
await reader.cancel();
|
|
227
|
+
} catch {
|
|
228
|
+
/* already closed / aborted */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Raised when the DM SSE returns 401 — the DM conversation_token has expired. */
|
|
234
|
+
export class DmStreamAuthError extends Error {
|
|
235
|
+
constructor() {
|
|
236
|
+
super("DM stream unauthorized (conversation token expired)");
|
|
237
|
+
this.name = "DmStreamAuthError";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Run a DM conversation SSE for as long as it stays open, reconnecting on
|
|
243
|
+
* transient drops. Resolves when the stream terminates for good (stop, 401, or
|
|
244
|
+
* abort). Never throws — terminal reasons are logged.
|
|
245
|
+
*/
|
|
246
|
+
export async function runDmStream(opts: DmStreamOptions): Promise<void> {
|
|
247
|
+
const log = opts.log ?? (() => {});
|
|
248
|
+
const delayMs = opts.reconnectDelayMs ?? 2000;
|
|
249
|
+
const maxErrors = opts.maxConsecutiveErrors ?? 8;
|
|
250
|
+
const signal = opts.signal;
|
|
251
|
+
// Shared across reconnects so catch-up + live delivery dedup by message id.
|
|
252
|
+
const handledIds = new Set<string>();
|
|
253
|
+
// CONSECUTIVE connection errors. A clean end resets it to 0, so a normally
|
|
254
|
+
// recycling stream never trips the cap — only a persistent failure does.
|
|
255
|
+
let consecutiveErrors = 0;
|
|
256
|
+
|
|
257
|
+
while (true) {
|
|
258
|
+
if (signal?.aborted) return;
|
|
259
|
+
try {
|
|
260
|
+
const reason = await consumeOnce(opts, handledIds);
|
|
261
|
+
consecutiveErrors = 0; // clean end (or stop) → reset the retry budget
|
|
262
|
+
if (reason === "stop") {
|
|
263
|
+
log(`[dm-stream] ${opts.conversationId} stopped\n`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// Clean end → reconnect after backoff.
|
|
267
|
+
} catch (err) {
|
|
268
|
+
if (err instanceof DmStreamAuthError) {
|
|
269
|
+
log(`[dm-stream] ${opts.conversationId} token expired — ending (peer can re-open)\n`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (signal?.aborted) return;
|
|
273
|
+
consecutiveErrors += 1;
|
|
274
|
+
log(
|
|
275
|
+
`[dm-stream] ${opts.conversationId}: ${err instanceof Error ? err.message : String(err)}, reconnecting (${consecutiveErrors}/${maxErrors})...\n`,
|
|
276
|
+
);
|
|
277
|
+
if (consecutiveErrors >= maxErrors) {
|
|
278
|
+
log(
|
|
279
|
+
`[dm-stream] ${opts.conversationId} giving up after ${maxErrors} consecutive errors — ending (peer can re-open)\n`,
|
|
280
|
+
);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (signal?.aborted) return;
|
|
285
|
+
await abortableDelay(delayMs, signal);
|
|
286
|
+
}
|
|
287
|
+
}
|
package/lib/fingerprint.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { realpathSync } from "node:fs";
|
|
|
8
8
|
// computed at bootstrap time and at channel start time would never match.
|
|
9
9
|
export function normalizePath(absPath: string): string {
|
|
10
10
|
if (process.platform !== "win32") return absPath;
|
|
11
|
-
// C:\Users
|
|
11
|
+
// C:\Users\<user>\... → /c/Users/<user>/...
|
|
12
12
|
let p = absPath.replace(/\\/g, "/");
|
|
13
13
|
const driveMatch = p.match(/^([A-Za-z]):\/(.*)$/);
|
|
14
14
|
if (driveMatch) {
|
package/lib/heartbeat.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instance liveness heartbeat scheduler (#311) — shared by every channel-kind
|
|
3
|
+
* client (viber-channel.ts AND viber-codex-bridge.ts).
|
|
4
|
+
*
|
|
5
|
+
* An agent (instance) is online as long as its instance heartbeat is fresh; the
|
|
6
|
+
* server's sweeper purges any instance whose heartbeat goes stale. So EVERY
|
|
7
|
+
* channel client must beat, or the sweeper will (correctly, by its rules) drop
|
|
8
|
+
* it from the Agents sidebar and signal its streams to stop. This module
|
|
9
|
+
* centralizes the beat so the two clients can't diverge.
|
|
10
|
+
*
|
|
11
|
+
* The token is read through a getter on every tick so the beat follows token
|
|
12
|
+
* refresh (#237) without re-wiring. (The legacy per-conversation heartbeat #298
|
|
13
|
+
* was removed in #315 once "Active" derived from instance liveness.)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { sendInstanceHeartbeat } from "./control_stream.ts";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the beat interval from the environment (seconds), defaulting to 25s.
|
|
22
|
+
* Must stay comfortably under the server timeout (VIBER_CHANNEL_PRESENCE_TIMEOUT,
|
|
23
|
+
* default 90s) so a single missed beat never trips the sweeper.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveHeartbeatIntervalMs(
|
|
26
|
+
env: Record<string, string | undefined> = process.env,
|
|
27
|
+
): number {
|
|
28
|
+
const raw = env.VIBER_CHANNEL_HEARTBEAT_INTERVAL_SECONDS;
|
|
29
|
+
const n = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
|
30
|
+
return Number.isFinite(n) && n > 0 ? n * 1000 : DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface HeartbeatHandle {
|
|
34
|
+
/** Cancel the scheduler. Idempotent. */
|
|
35
|
+
stop: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface InstanceHeartbeatOptions {
|
|
39
|
+
baseUrl: string;
|
|
40
|
+
instanceId: string;
|
|
41
|
+
/** Current instance token — read each tick (follows refresh). */
|
|
42
|
+
getInstanceToken: () => string;
|
|
43
|
+
intervalMs?: number;
|
|
44
|
+
log?: (line: string) => void;
|
|
45
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
46
|
+
clearTimer?: (handle: unknown) => void;
|
|
47
|
+
/** Injectable beat fn for tests. Defaults to sendInstanceHeartbeat. */
|
|
48
|
+
beat?: (baseUrl: string, instanceId: string, instanceToken: string) => Promise<boolean>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Start beating the INSTANCE control-stream liveness (#311). Instance-scoped: an
|
|
53
|
+
* agent is online as long as its instance heartbeat is fresh, independent of any
|
|
54
|
+
* conversation (it can be in await-invite with no conversation, or in several).
|
|
55
|
+
* Single-flight + unref'd timer so the beat never keeps a doomed process alive on
|
|
56
|
+
* its own.
|
|
57
|
+
*/
|
|
58
|
+
export function startInstanceHeartbeat(opts: InstanceHeartbeatOptions): HeartbeatHandle {
|
|
59
|
+
const intervalMs = opts.intervalMs ?? resolveHeartbeatIntervalMs();
|
|
60
|
+
const log = opts.log ?? ((line: string) => process.stderr.write(line));
|
|
61
|
+
const setTimer =
|
|
62
|
+
opts.setTimer ?? ((fn: () => void, ms: number) => setInterval(fn, ms) as unknown);
|
|
63
|
+
const clearTimer =
|
|
64
|
+
opts.clearTimer ??
|
|
65
|
+
((h: unknown) => clearInterval(h as ReturnType<typeof setInterval>));
|
|
66
|
+
const beat = opts.beat ?? sendInstanceHeartbeat;
|
|
67
|
+
|
|
68
|
+
let inFlight = false;
|
|
69
|
+
const handle = setTimer(() => {
|
|
70
|
+
if (inFlight) return;
|
|
71
|
+
inFlight = true;
|
|
72
|
+
void beat(opts.baseUrl, opts.instanceId, opts.getInstanceToken())
|
|
73
|
+
.then((ok) => {
|
|
74
|
+
if (!ok) {
|
|
75
|
+
log("[viber-channel] instance heartbeat failed (will retry next interval)\n");
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
.finally(() => {
|
|
79
|
+
inFlight = false;
|
|
80
|
+
});
|
|
81
|
+
}, intervalMs);
|
|
82
|
+
|
|
83
|
+
if (handle && typeof (handle as { unref?: () => void }).unref === "function") {
|
|
84
|
+
(handle as { unref: () => void }).unref();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let stopped = false;
|
|
88
|
+
return {
|
|
89
|
+
stop: () => {
|
|
90
|
+
if (stopped) return;
|
|
91
|
+
stopped = true;
|
|
92
|
+
clearTimer(handle);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/lib/instance.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* instance.ts — acquire and use the server-issued per-instance identity (#269).
|
|
3
|
+
*
|
|
4
|
+
* The instance_token is the durable, opaque per-agent credential (D4) the channel
|
|
5
|
+
* presents to JOIN a conversation. It replaces the locally-computed
|
|
6
|
+
* client_fingerprint as the identity source. Acquisition is self-healing: an
|
|
7
|
+
* existing install with no instance_token registers one on first run with its
|
|
8
|
+
* stable project_token (no forced manual reconnect).
|
|
9
|
+
*/
|
|
10
|
+
import { cfAccessHeaders } from "./cfAccess.js";
|
|
11
|
+
import { authFilePath, persistInstanceCredentials, type AuthJson } from "./auth.js";
|
|
12
|
+
|
|
13
|
+
export interface RegisterInstanceResponse {
|
|
14
|
+
instance_id: string;
|
|
15
|
+
instance_token: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Non-2xx from POST /api/projects/:id/instances. `.status` lets the caller
|
|
20
|
+
* distinguish a revoked/invalid project_token (401 → reconnect) from a transient
|
|
21
|
+
* server error.
|
|
22
|
+
*/
|
|
23
|
+
export class InstanceRegisterError extends Error {
|
|
24
|
+
status: number;
|
|
25
|
+
constructor(status: number, detail: string) {
|
|
26
|
+
super(`Failed to register instance (HTTP ${status}): ${detail}`);
|
|
27
|
+
this.name = "InstanceRegisterError";
|
|
28
|
+
this.status = status;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* POST /api/projects/:id/instances — mint a durable per-instance identity.
|
|
34
|
+
* Authenticated by the stable project_token (the orchestrator credential);
|
|
35
|
+
* the worker also wants X-Client-Fingerprint as a stored signal. Returns the
|
|
36
|
+
* opaque instance_token + its server id.
|
|
37
|
+
*/
|
|
38
|
+
export async function registerInstance(
|
|
39
|
+
baseUrl: string,
|
|
40
|
+
projectId: number,
|
|
41
|
+
projectToken: string,
|
|
42
|
+
fingerprint: string,
|
|
43
|
+
label?: string,
|
|
44
|
+
kind?: string,
|
|
45
|
+
): Promise<RegisterInstanceResponse> {
|
|
46
|
+
const body: Record<string, string> = {};
|
|
47
|
+
if (label !== undefined) body.label = label;
|
|
48
|
+
if (kind !== undefined) body.kind = kind; // runtime: "claude-code" | "codex" | … (#280)
|
|
49
|
+
const resp = await fetch(`${baseUrl}/api/projects/${projectId}/instances`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
Authorization: `Bearer ${projectToken}`,
|
|
53
|
+
"X-Client-Fingerprint": fingerprint,
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
...cfAccessHeaders(),
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
});
|
|
59
|
+
if (!resp.ok) {
|
|
60
|
+
const detail = await resp.text().catch(() => "");
|
|
61
|
+
throw new InstanceRegisterError(resp.status, detail);
|
|
62
|
+
}
|
|
63
|
+
return (await resp.json()) as RegisterInstanceResponse;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The control-plane `kind` for instances this process registers, from
|
|
68
|
+
* `VIBER_INSTANCE_KIND` (set by the spawn path — vibe-master's agent_spec, #318
|
|
69
|
+
* step-01). undefined when unset → the worker defaults to "claude-code", the
|
|
70
|
+
* historical behavior for a plain channel session. A bridge runtime (codex,
|
|
71
|
+
* gemma, hermes) sets it so its instance is tagged correctly in the control
|
|
72
|
+
* plane instead of mislabelled claude-code.
|
|
73
|
+
*/
|
|
74
|
+
export function instanceKindFromEnv(): string | undefined {
|
|
75
|
+
const raw = process.env.VIBER_INSTANCE_KIND;
|
|
76
|
+
return raw !== undefined && raw.trim() !== "" ? raw.trim() : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface AcquiredInstance {
|
|
80
|
+
instance_token: string;
|
|
81
|
+
/**
|
|
82
|
+
* Stable per-instance key used to namespace the conversation handle. The
|
|
83
|
+
* server `instance_id` when known; otherwise the `instance_token` itself
|
|
84
|
+
* (env-injected agents may not carry the id). Both are 1:1 with the instance
|
|
85
|
+
* and durable, so either isolates the handle correctly.
|
|
86
|
+
*/
|
|
87
|
+
instance_key: string;
|
|
88
|
+
/**
|
|
89
|
+
* The real server-issued `instance_id`, or undefined when only a bare token is
|
|
90
|
+
* known (env-injected token without VIBER_INSTANCE_ID, or a persisted token with
|
|
91
|
+
* no persisted id). Distinct from `instance_key` — which may be the TOKEN as a
|
|
92
|
+
* namespacing fallback. Use THIS for the self-echo guard + sender attribution:
|
|
93
|
+
* the token must never be compared against server instance ids in
|
|
94
|
+
* `sender_instance_id`; undefined → the self-echo guard fails open (delivers everything).
|
|
95
|
+
*/
|
|
96
|
+
instance_id?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve this channel's instance identity (#269), in priority order:
|
|
101
|
+
* 1. `VIBER_INSTANCE_TOKEN` env (orchestrator-injected, folder-less agents) —
|
|
102
|
+
* used verbatim; `VIBER_INSTANCE_ID` keys the handle if also provided, else
|
|
103
|
+
* the token does.
|
|
104
|
+
* 2. `VIBER_CHANNEL_LABEL` env (#280 step-25 — launcher-named agent session):
|
|
105
|
+
* register a FRESH instance carrying that label, NOT persisted — reusing or
|
|
106
|
+
* overwriting the shared auth.json identity would evict the user's main
|
|
107
|
+
* channel session (the #252 instance-collision gotcha).
|
|
108
|
+
* 3. `auth.json` `instance_token` — reused (durable identity, D4 → same id).
|
|
109
|
+
* 4. register a NEW instance with the stable project_token, persisted back to
|
|
110
|
+
* auth.json (self-healing migration — no forced manual reconnect).
|
|
111
|
+
*/
|
|
112
|
+
export async function acquireInstance(
|
|
113
|
+
baseUrl: string,
|
|
114
|
+
auth: AuthJson,
|
|
115
|
+
fingerprint: string,
|
|
116
|
+
log: (msg: string) => void = (m) => process.stderr.write(m),
|
|
117
|
+
cwd: string = process.cwd(),
|
|
118
|
+
): Promise<AcquiredInstance> {
|
|
119
|
+
const envToken = process.env.VIBER_INSTANCE_TOKEN;
|
|
120
|
+
if (envToken !== undefined && envToken.trim() !== "") {
|
|
121
|
+
const token = envToken.trim();
|
|
122
|
+
const envId = process.env.VIBER_INSTANCE_ID;
|
|
123
|
+
const realId = envId !== undefined && envId.trim() !== "" ? envId.trim() : undefined;
|
|
124
|
+
const key = realId ?? token;
|
|
125
|
+
log(`[viber-channel] instance: using VIBER_INSTANCE_TOKEN (env-injected)\n`);
|
|
126
|
+
return { instance_token: token, instance_key: key, instance_id: realId };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const envLabel = process.env.VIBER_CHANNEL_LABEL;
|
|
130
|
+
if (envLabel !== undefined && envLabel.trim() !== "") {
|
|
131
|
+
const label = envLabel.trim();
|
|
132
|
+
log(`[viber-channel] instance: registering fresh labelled instance "${label}"\n`);
|
|
133
|
+
const reg = await registerInstance(
|
|
134
|
+
baseUrl,
|
|
135
|
+
auth.project_id,
|
|
136
|
+
auth.project_token,
|
|
137
|
+
fingerprint,
|
|
138
|
+
label,
|
|
139
|
+
instanceKindFromEnv(),
|
|
140
|
+
);
|
|
141
|
+
return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (auth.instance_token !== undefined && auth.instance_token.trim() !== "") {
|
|
145
|
+
const realId =
|
|
146
|
+
auth.instance_id !== undefined && auth.instance_id.trim() !== "" ? auth.instance_id : undefined;
|
|
147
|
+
const key = realId ?? auth.instance_token;
|
|
148
|
+
log(`[viber-channel] instance: reusing persisted instance_token (id=${auth.instance_id ?? "?"})\n`);
|
|
149
|
+
return { instance_token: auth.instance_token, instance_key: key, instance_id: realId };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
log(`[viber-channel] instance: none found — registering a new one via the project_token\n`);
|
|
153
|
+
const reg = await registerInstance(
|
|
154
|
+
baseUrl,
|
|
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 };
|
|
164
|
+
}
|
package/lib/lockfile.ts
CHANGED
|
@@ -48,3 +48,39 @@ export function lockFilePath(
|
|
|
48
48
|
.slice(0, 8);
|
|
49
49
|
return join(lockDir, `channel-${suffix}.lock`);
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the lock's session-id axis from the environment (#259/#269/#293).
|
|
54
|
+
*
|
|
55
|
+
* Priority (first non-empty wins):
|
|
56
|
+
* 1. `VIBER_CHANNEL_SESSION_ID` — explicit per-launch id (launchers, #259).
|
|
57
|
+
* 2. `VIBER_INSTANCE_TOKEN` → `inst-<hash>` — env-injected fleet agents (#269).
|
|
58
|
+
* 3. `VIBER_CHANNEL_LABEL` → `label-<hash>` — a deliberate 2nd agent in the SAME
|
|
59
|
+
* folder (#293): the label already gives it a fresh instance identity, so
|
|
60
|
+
* derive a distinct lock from it too. Without this it would share the 1st
|
|
61
|
+
* agent's lock and be rejected as a duplicate.
|
|
62
|
+
* 4. `""` — plain bunx client / accidental respawn: shares the per-folder lock
|
|
63
|
+
* so accidental duplicates stay blocked (#166).
|
|
64
|
+
*
|
|
65
|
+
* Tokens/labels are HASHED (never raw) because the result is logged as a prefix.
|
|
66
|
+
*/
|
|
67
|
+
export function lockSessionId(env: NodeJS.ProcessEnv): string {
|
|
68
|
+
// A non-blank explicit id wins verbatim. A BLANK one (set to "" / whitespace)
|
|
69
|
+
// is treated as ABSENT so it falls through to the token/label tiers — otherwise
|
|
70
|
+
// `VIBER_CHANNEL_SESSION_ID="" + VIBER_CHANNEL_LABEL=x` would yield "" and the
|
|
71
|
+
// labelled 2nd agent would still collide (review: Opus #1 / Codex).
|
|
72
|
+
const explicit = env.VIBER_CHANNEL_SESSION_ID;
|
|
73
|
+
if (explicit !== undefined && explicit.trim() !== "") return explicit;
|
|
74
|
+
const token = env.VIBER_INSTANCE_TOKEN?.trim();
|
|
75
|
+
if (token !== undefined && token !== "") {
|
|
76
|
+
// Trim before hashing (consistent with acquireInstance, which uses the
|
|
77
|
+
// trimmed token as the identity) so the lock and the identity derive from
|
|
78
|
+
// the same effective value.
|
|
79
|
+
return `inst-${createHash("sha256").update(token).digest("hex").slice(0, 16)}`;
|
|
80
|
+
}
|
|
81
|
+
const label = env.VIBER_CHANNEL_LABEL;
|
|
82
|
+
if (label !== undefined && label.trim() !== "") {
|
|
83
|
+
return `label-${createHash("sha256").update(label.trim()).digest("hex").slice(0, 16)}`;
|
|
84
|
+
}
|
|
85
|
+
return "";
|
|
86
|
+
}
|
package/lib/messages.ts
CHANGED
|
@@ -71,6 +71,30 @@ export function parseArtifact(raw: unknown): ParseArtifactResult {
|
|
|
71
71
|
return { ok: true, artifact: { content, format: rawArtifact.format as ArtifactFormat } };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* GET /api/conversations/:id/messages with a conversation_token (multi-auth).
|
|
76
|
+
*
|
|
77
|
+
* Used by the bridge to CATCH UP on messages it missed while its SSE was
|
|
78
|
+
* disconnected (#280 step-06): the conversation SSE has no replay, so on each
|
|
79
|
+
* reconnect the bridge re-reads the message list and processes any it hasn't
|
|
80
|
+
* seen. Returns the raw message array (ascending id) or throws on failure /
|
|
81
|
+
* `ConversationTokenExpiredError` on 401 so the caller can refresh + retry.
|
|
82
|
+
*/
|
|
83
|
+
export async function fetchMessages(
|
|
84
|
+
baseUrl: string,
|
|
85
|
+
conversationId: string,
|
|
86
|
+
conversationToken: string,
|
|
87
|
+
): Promise<Array<Record<string, unknown>>> {
|
|
88
|
+
const resp = await fetch(`${baseUrl}/api/conversations/${conversationId}/messages`, {
|
|
89
|
+
method: "GET",
|
|
90
|
+
headers: { Authorization: `Bearer ${conversationToken}`, ...cfAccessHeaders() },
|
|
91
|
+
});
|
|
92
|
+
if (resp.status === 401) throw new ConversationTokenExpiredError();
|
|
93
|
+
if (!resp.ok) throw new Error(`fetchMessages failed: HTTP ${resp.status}`);
|
|
94
|
+
const body = (await resp.json()) as { messages?: Array<Record<string, unknown>> };
|
|
95
|
+
return Array.isArray(body.messages) ? body.messages : [];
|
|
96
|
+
}
|
|
97
|
+
|
|
74
98
|
export interface MessagePostSuccess {
|
|
75
99
|
ok: true;
|
|
76
100
|
message_id: string;
|
|
@@ -97,6 +121,13 @@ export async function postMessage(
|
|
|
97
121
|
conversationToken: string,
|
|
98
122
|
text: string,
|
|
99
123
|
artifact?: Artifact,
|
|
124
|
+
/**
|
|
125
|
+
* Optional abort signal (#303 step-12.03): the codex bridge passes its stream's
|
|
126
|
+
* signal so a reply in flight is cancelled atomically if the stream is aborted
|
|
127
|
+
* (re-join / revoke / shutdown) — closing the check-then-post window that a bare
|
|
128
|
+
* `signal.aborted` guard before the call cannot.
|
|
129
|
+
*/
|
|
130
|
+
signal?: AbortSignal,
|
|
100
131
|
): Promise<MessagePostResult> {
|
|
101
132
|
if (!text || text.trim().length === 0) {
|
|
102
133
|
return {
|
|
@@ -118,6 +149,7 @@ export async function postMessage(
|
|
|
118
149
|
...cfAccessHeaders(),
|
|
119
150
|
},
|
|
120
151
|
body: JSON.stringify(artifact ? { content: text, artifact } : { content: text }),
|
|
152
|
+
signal,
|
|
121
153
|
});
|
|
122
154
|
} catch (err) {
|
|
123
155
|
return {
|