viber-channel 0.6.0 → 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/lib/agent_tools.ts +201 -0
- package/lib/bridge_core.ts +1019 -0
- package/lib/bridge_spawn.ts +18 -1
- package/lib/bridge_tool_host.ts +191 -0
- package/lib/control_stream.ts +44 -0
- package/lib/dm_stream.ts +287 -0
- package/lib/fingerprint.ts +1 -1
- package/lib/heartbeat.ts +95 -0
- package/lib/instance.ts +29 -2
- package/lib/lockfile.ts +36 -0
- package/lib/messages.ts +8 -0
- package/lib/ollama_chat.ts +106 -0
- package/lib/parent_watchdog.ts +276 -0
- package/lib/peers.ts +113 -0
- package/lib/supervisor.ts +11 -0
- package/lib/supervisor_config.ts +20 -1
- package/package.json +3 -1
- package/viber-channel.ts +252 -69
- package/viber-codex-bridge.ts +487 -473
- package/viber-gemma-bridge.ts +169 -0
package/lib/bridge_spawn.ts
CHANGED
|
@@ -69,12 +69,29 @@ export function createBridgeSpawn(opts: BridgeSpawnOptions = {}): SpawnFn {
|
|
|
69
69
|
// Force a fresh per-agent instance (see file header).
|
|
70
70
|
delete env.VIBER_INSTANCE_TOKEN;
|
|
71
71
|
delete env.VIBER_INSTANCE_ID;
|
|
72
|
+
// #309 step-17: NEVER inherit the identity flag/role from the parent
|
|
73
|
+
// supervisor — a leaked VIBER_AGENT_NAME_EXPLICIT would make an auto id
|
|
74
|
+
// (agent-1) claim itself as an explicit name. Strip them here and re-derive
|
|
75
|
+
// authoritatively below (codex-309-review blocking #1).
|
|
76
|
+
delete env.VIBER_AGENT_NAME_EXPLICIT;
|
|
77
|
+
delete env.VIBER_AGENT_ROLE;
|
|
72
78
|
env.VIBER_AGENT_TIER = spec.tier;
|
|
79
|
+
if (spec.env) Object.assign(env, spec.env);
|
|
80
|
+
// Identity is set LAST, AFTER spec.env, so neither the inherited env nor a
|
|
81
|
+
// config `env` block can spoof the explicit-name flag or the role — the
|
|
82
|
+
// spec fields are the only source of truth (codex-309-review blocking #1).
|
|
73
83
|
// Surface the agent id as the bridge's Viber instance label so distinct
|
|
74
84
|
// agents are identifiable in the control plane (participants strip / recipient
|
|
75
85
|
// picker) instead of all showing the same generic "Codex bridge" label.
|
|
76
86
|
env.VIBER_CODEX_BRIDGE_LABEL = spec.agentId;
|
|
77
|
-
|
|
87
|
+
// Only an EXPLICIT name is claimed as the agent's name (the label is always
|
|
88
|
+
// set, even for auto ids); the separate flag is the safe signal. `else delete`
|
|
89
|
+
// closes the spoof path even if spec.env smuggled the flag in.
|
|
90
|
+
if (spec.nameExplicit) env.VIBER_AGENT_NAME_EXPLICIT = "1";
|
|
91
|
+
else delete env.VIBER_AGENT_NAME_EXPLICIT;
|
|
92
|
+
// The role is untrusted text the bridge sanitizes before injecting it.
|
|
93
|
+
if (spec.role !== undefined) env.VIBER_AGENT_ROLE = spec.role;
|
|
94
|
+
else delete env.VIBER_AGENT_ROLE;
|
|
78
95
|
|
|
79
96
|
const child = spawnImpl(command, [...baseArgs, ...spec.args], {
|
|
80
97
|
env,
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bridge_tool_host.ts — in-process MCP tool host for the Codex bridge (#317 iii).
|
|
3
|
+
*
|
|
4
|
+
* The bridge hosts a streamable-HTTP MCP server on loopback; `codex app-server`
|
|
5
|
+
* connects to it BY URL (mcp_servers.<name>.url). Because the server runs INSIDE
|
|
6
|
+
* the bridge process (no child spawn), the tools read the bridge's LIVE context
|
|
7
|
+
* (rotating token, current-turn conversation) by reference — never a snapshot.
|
|
8
|
+
* This is the same `agent_tools.ts` the Claude MCP uses: one source, no double
|
|
9
|
+
* maintenance.
|
|
10
|
+
*
|
|
11
|
+
* Security (viber-dev-315): the server binds to 127.0.0.1 only and requires an
|
|
12
|
+
* ephemeral bearer token, so no other local process can post into the channel
|
|
13
|
+
* through it.
|
|
14
|
+
*
|
|
15
|
+
* Anti-double-post (#317 #D): the bridge auto-posts a turn's final text only when
|
|
16
|
+
* NO outbound tool succeeded that turn. The host reports a SUCCESSFUL
|
|
17
|
+
* send_message / message_agent via `onOutboundSuccess` so the caller can suppress
|
|
18
|
+
* the auto-post. A FAILED outbound does NOT report success → the auto-post
|
|
19
|
+
* fallback still fires (no silent void). list_agents is read-only and never
|
|
20
|
+
* reports outbound.
|
|
21
|
+
*/
|
|
22
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
23
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
24
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
25
|
+
import {
|
|
26
|
+
listAgents,
|
|
27
|
+
messageAgent,
|
|
28
|
+
sendMessage,
|
|
29
|
+
type AgentToolsContext,
|
|
30
|
+
type AgentToolResult,
|
|
31
|
+
} from "./agent_tools.js";
|
|
32
|
+
|
|
33
|
+
/** The three channel tools, named so codex (and the elicitation whitelist) match. */
|
|
34
|
+
export const BRIDGE_TOOL_NAMES = ["send_message", "list_agents", "message_agent"] as const;
|
|
35
|
+
export type BridgeToolName = (typeof BRIDGE_TOOL_NAMES)[number];
|
|
36
|
+
|
|
37
|
+
/** Outbound tools whose SUCCESS suppresses the bridge's final auto-post. */
|
|
38
|
+
const OUTBOUND_TOOLS: ReadonlySet<string> = new Set(["send_message", "message_agent"]);
|
|
39
|
+
|
|
40
|
+
const TOOL_DEFS = [
|
|
41
|
+
{
|
|
42
|
+
name: "send_message",
|
|
43
|
+
description:
|
|
44
|
+
"Post your reply into the current Viber conversation. `text` is the conversational reply — " +
|
|
45
|
+
"read aloud AND shown as Markdown, so keep it short and natural but use light Markdown (short " +
|
|
46
|
+
"lists, numbered steps, **bold**) for readability; put actions/questions here, visibly. " +
|
|
47
|
+
"`artifact` is optional HEAVY/LONG content (big code, large tables, long analyses, json, " +
|
|
48
|
+
"sanitized html) shown in a side viewer; when used, keep `text` a brief summary pointing to it. " +
|
|
49
|
+
"Calling this IS your reply for the turn.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
text: { type: "string", description: "Spoken/conversational reply. Plain sentences." },
|
|
54
|
+
artifact: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
content: { type: "string" },
|
|
58
|
+
format: { type: "string", enum: ["markdown", "code", "json", "html"] },
|
|
59
|
+
},
|
|
60
|
+
required: ["content"],
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: ["text"],
|
|
65
|
+
additionalProperties: false,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "list_agents",
|
|
70
|
+
description:
|
|
71
|
+
"List the OTHER agents (instances) in this project you can DM. Returns each agent's id, " +
|
|
72
|
+
"label, runtime kind, and whether it is online. Use it to find an id before message_agent.",
|
|
73
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "message_agent",
|
|
77
|
+
description:
|
|
78
|
+
"Send a direct message to another agent in this project. Pass the target agent's instance id " +
|
|
79
|
+
"(from list_agents) and the text. Opens or reuses a private 1:1 DM and posts your message; " +
|
|
80
|
+
"the agent's reply arrives back on this channel. Fails if the target agent is offline.",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {
|
|
84
|
+
instance_id: { type: "string", description: "Target agent's instance id (from list_agents)." },
|
|
85
|
+
text: { type: "string", description: "Message to send to the target agent." },
|
|
86
|
+
},
|
|
87
|
+
required: ["instance_id", "text"],
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
] as const;
|
|
92
|
+
|
|
93
|
+
export interface BridgeToolHost {
|
|
94
|
+
/** URL codex connects to: http://127.0.0.1:<port>/mcp */
|
|
95
|
+
url: string;
|
|
96
|
+
/** Ephemeral bearer token value the host requires (pass to codex via env). */
|
|
97
|
+
bearerToken: string;
|
|
98
|
+
/** Stop the HTTP server. */
|
|
99
|
+
stop(): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface BridgeToolHostOptions {
|
|
103
|
+
/** Live bridge context (per-turn conversation, rotating token, DM streaming). */
|
|
104
|
+
ctx: AgentToolsContext;
|
|
105
|
+
/** Called with the tool name when an OUTBOUND tool (send_message/message_agent) SUCCEEDS. */
|
|
106
|
+
onOutboundSuccess: (tool: BridgeToolName) => void;
|
|
107
|
+
/** stderr-style logger. */
|
|
108
|
+
log: (line: string) => void;
|
|
109
|
+
/** Generate the ephemeral bearer token (injected for testability). */
|
|
110
|
+
makeToken: () => string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Dispatch a single tool call to the shared lib and, on a successful outbound,
|
|
115
|
+
* report it. Exported for unit testing without spinning up the HTTP server.
|
|
116
|
+
*/
|
|
117
|
+
export async function dispatchBridgeTool(
|
|
118
|
+
name: string,
|
|
119
|
+
args: Record<string, unknown>,
|
|
120
|
+
opts: Pick<BridgeToolHostOptions, "ctx" | "onOutboundSuccess">,
|
|
121
|
+
): Promise<AgentToolResult> {
|
|
122
|
+
let result: AgentToolResult;
|
|
123
|
+
if (name === "list_agents") {
|
|
124
|
+
result = await listAgents(opts.ctx);
|
|
125
|
+
} else if (name === "message_agent") {
|
|
126
|
+
result = await messageAgent(opts.ctx, args);
|
|
127
|
+
} else if (name === "send_message") {
|
|
128
|
+
result = await sendMessage(opts.ctx, args);
|
|
129
|
+
} else {
|
|
130
|
+
return { isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] };
|
|
131
|
+
}
|
|
132
|
+
// Only a SUCCESSFUL outbound suppresses the auto-post (#317 #D: success, not attempt).
|
|
133
|
+
if (OUTBOUND_TOOLS.has(name) && result.isError !== true) {
|
|
134
|
+
opts.onOutboundSuccess(name as BridgeToolName);
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function buildServer(opts: BridgeToolHostOptions): Server {
|
|
140
|
+
const mcp = new Server({ name: "viber-channel-tools", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
141
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS as unknown as object[] }));
|
|
142
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
143
|
+
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
|
|
144
|
+
try {
|
|
145
|
+
return await dispatchBridgeTool(request.params.name, args, opts);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// The shared lib rethrows ConversationTokenExpiredError (host-specific
|
|
148
|
+
// teardown). In the bridge we surface it as a tool error rather than
|
|
149
|
+
// killing the turn mid-flight: the stream's own next op hits 401 and the
|
|
150
|
+
// refresh/abort path handles renewal. NOT reported as outbound success.
|
|
151
|
+
opts.log(`[bridge-tool-host] tool ${request.params.name} threw: ${String(err)}\n`);
|
|
152
|
+
return { isError: true, content: [{ type: "text", text: `Tool error: ${String(err)}` }] };
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
return mcp;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Start the loopback MCP HTTP host. Returns the URL + bearer for codex config.
|
|
160
|
+
* Stateless transport → a fresh Server+transport per request (SDK requirement).
|
|
161
|
+
*/
|
|
162
|
+
export function startBridgeToolHost(opts: BridgeToolHostOptions): BridgeToolHost {
|
|
163
|
+
const bearerToken = opts.makeToken();
|
|
164
|
+
const server = Bun.serve({
|
|
165
|
+
hostname: "127.0.0.1",
|
|
166
|
+
port: 0,
|
|
167
|
+
async fetch(req) {
|
|
168
|
+
const auth = req.headers.get("authorization") ?? "";
|
|
169
|
+
if (auth !== `Bearer ${bearerToken}`) {
|
|
170
|
+
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
171
|
+
status: 401,
|
|
172
|
+
headers: { "content-type": "application/json" },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const mcp = buildServer(opts);
|
|
176
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
177
|
+
sessionIdGenerator: undefined,
|
|
178
|
+
enableJsonResponse: true,
|
|
179
|
+
});
|
|
180
|
+
await mcp.connect(transport);
|
|
181
|
+
return transport.handleRequest(req);
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const url = `http://127.0.0.1:${server.port}/mcp`;
|
|
185
|
+
opts.log(`[bridge-tool-host] MCP tools on ${url} (loopback, bearer-guarded)\n`);
|
|
186
|
+
return {
|
|
187
|
+
url,
|
|
188
|
+
bearerToken,
|
|
189
|
+
stop: () => server.stop(true),
|
|
190
|
+
};
|
|
191
|
+
}
|
package/lib/control_stream.ts
CHANGED
|
@@ -31,6 +31,39 @@ export function buildControlStreamUrl(baseUrl: string, instanceId: string): stri
|
|
|
31
31
|
return `${baseUrl}/api/instances/${instanceId}/events`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* POST /api/instances/:id/heartbeat — instance control-stream liveness (#311).
|
|
36
|
+
*
|
|
37
|
+
* The agent beats on a fixed interval so the server's #311 sweeper never purges a
|
|
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. Best-effort —
|
|
40
|
+
* `true` on 2xx, `false` on any non-2xx / network error. Bounded by a per-POST
|
|
41
|
+
* timeout so a hung tunnel socket can't stack beats (mirrors #298).
|
|
42
|
+
*/
|
|
43
|
+
export async function sendInstanceHeartbeat(
|
|
44
|
+
baseUrl: string,
|
|
45
|
+
instanceId: string,
|
|
46
|
+
instanceToken: string,
|
|
47
|
+
timeoutMs = 20_000,
|
|
48
|
+
fetchImpl: typeof fetch = fetch,
|
|
49
|
+
): Promise<boolean> {
|
|
50
|
+
try {
|
|
51
|
+
const resp = await fetchImpl(`${baseUrl}/api/instances/${instanceId}/heartbeat`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${instanceToken}`,
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
...cfAccessHeaders(),
|
|
57
|
+
},
|
|
58
|
+
body: "{}",
|
|
59
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
60
|
+
});
|
|
61
|
+
return resp.ok;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
34
67
|
export interface ControlStreamOptions {
|
|
35
68
|
baseUrl: string;
|
|
36
69
|
instanceId: string;
|
|
@@ -204,6 +237,13 @@ export interface PersistentControlStreamHandlers {
|
|
|
204
237
|
* The agent should shut down. NOT called on transient drops (those reconnect).
|
|
205
238
|
*/
|
|
206
239
|
onStop?: (reason: "stopped" | "unauthorized") => void;
|
|
240
|
+
/**
|
|
241
|
+
* Called on a `beat_now` event (#311): the server is actively probing this
|
|
242
|
+
* agent's liveness — respond with an IMMEDIATE instance heartbeat so a live
|
|
243
|
+
* agent is confirmed within seconds (and a dead one, which never gets here, is
|
|
244
|
+
* force-offlined at the probe deadline).
|
|
245
|
+
*/
|
|
246
|
+
onBeatNow?: () => void;
|
|
207
247
|
log?: (msg: string) => void;
|
|
208
248
|
/** Backoff before reconnecting after a drop / clean end. Default 2000ms. */
|
|
209
249
|
reconnectDelayMs?: number;
|
|
@@ -270,6 +310,10 @@ export function runPersistentControlStream(
|
|
|
270
310
|
handlers.onStop?.("stopped");
|
|
271
311
|
return;
|
|
272
312
|
}
|
|
313
|
+
if (ev.event === "beat_now") {
|
|
314
|
+
handlers.onBeatNow?.();
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
273
317
|
if (ev.event !== "join") continue; // connected / ping / unknown
|
|
274
318
|
const minted = parseJoinPayload(ev.data, log);
|
|
275
319
|
if (!minted) continue;
|
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
|
+
}
|