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.
@@ -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
+ }
@@ -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\jeanp\... → /c/Users/jeanp/...
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) {
@@ -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
+ }
@@ -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 {