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/instance.ts
CHANGED
|
@@ -63,6 +63,19 @@ export async function registerInstance(
|
|
|
63
63
|
return (await resp.json()) as RegisterInstanceResponse;
|
|
64
64
|
}
|
|
65
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
|
+
|
|
66
79
|
export interface AcquiredInstance {
|
|
67
80
|
instance_token: string;
|
|
68
81
|
/**
|
|
@@ -117,7 +130,14 @@ export async function acquireInstance(
|
|
|
117
130
|
if (envLabel !== undefined && envLabel.trim() !== "") {
|
|
118
131
|
const label = envLabel.trim();
|
|
119
132
|
log(`[viber-channel] instance: registering fresh labelled instance "${label}"\n`);
|
|
120
|
-
const reg = await registerInstance(
|
|
133
|
+
const reg = await registerInstance(
|
|
134
|
+
baseUrl,
|
|
135
|
+
auth.project_id,
|
|
136
|
+
auth.project_token,
|
|
137
|
+
fingerprint,
|
|
138
|
+
label,
|
|
139
|
+
instanceKindFromEnv(),
|
|
140
|
+
);
|
|
121
141
|
return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
|
|
122
142
|
}
|
|
123
143
|
|
|
@@ -130,7 +150,14 @@ export async function acquireInstance(
|
|
|
130
150
|
}
|
|
131
151
|
|
|
132
152
|
log(`[viber-channel] instance: none found — registering a new one via the project_token\n`);
|
|
133
|
-
const reg = await registerInstance(
|
|
153
|
+
const reg = await registerInstance(
|
|
154
|
+
baseUrl,
|
|
155
|
+
auth.project_id,
|
|
156
|
+
auth.project_token,
|
|
157
|
+
fingerprint,
|
|
158
|
+
undefined,
|
|
159
|
+
instanceKindFromEnv(),
|
|
160
|
+
);
|
|
134
161
|
persistInstanceCredentials(authFilePath(cwd), reg.instance_id, reg.instance_token, log);
|
|
135
162
|
log(`[viber-channel] instance: registered + persisted (id=${reg.instance_id})\n`);
|
|
136
163
|
return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
|
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
|
@@ -121,6 +121,13 @@ export async function postMessage(
|
|
|
121
121
|
conversationToken: string,
|
|
122
122
|
text: string,
|
|
123
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,
|
|
124
131
|
): Promise<MessagePostResult> {
|
|
125
132
|
if (!text || text.trim().length === 0) {
|
|
126
133
|
return {
|
|
@@ -142,6 +149,7 @@ export async function postMessage(
|
|
|
142
149
|
...cfAccessHeaders(),
|
|
143
150
|
},
|
|
144
151
|
body: JSON.stringify(artifact ? { content: text, artifact } : { content: text }),
|
|
152
|
+
signal,
|
|
145
153
|
});
|
|
146
154
|
} catch (err) {
|
|
147
155
|
return {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ollama_chat.ts — minimal ollama /api/chat client for the Gemma runtime (#318
|
|
3
|
+
* step-02).
|
|
4
|
+
*
|
|
5
|
+
* Gemma is a "bare LLM" runtime: unlike Codex it does not host MCP tools or run
|
|
6
|
+
* an app-server — the bridge simply feeds the conversation to ollama and posts
|
|
7
|
+
* the assistant's reply back into Viber. This module is that single call,
|
|
8
|
+
* isolated and pure (HTTP in → text out) so it is unit-testable without a live
|
|
9
|
+
* ollama.
|
|
10
|
+
*
|
|
11
|
+
* Ported from the existing in-process Python integration (`viber/llm.py` +
|
|
12
|
+
* `viber/standalone_ai.py`, #188): same model id `gemma4:e4b`, same chat-message
|
|
13
|
+
* shape, same "ollama not running" error semantics — reimplemented in TS for the
|
|
14
|
+
* control-plane bridge (D1: a TS bridge, not promoting the Python path).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A chat turn, matching ollama's /api/chat message shape. */
|
|
18
|
+
export interface ChatMessage {
|
|
19
|
+
role: "system" | "user" | "assistant";
|
|
20
|
+
content: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Raised when the ollama daemon is unreachable (not running / wrong host). */
|
|
24
|
+
export class OllamaUnavailableError extends Error {
|
|
25
|
+
constructor(baseUrl: string, cause?: unknown) {
|
|
26
|
+
super(
|
|
27
|
+
`ollama is not reachable at ${baseUrl} — start it with \`ollama serve\` (cause: ${cause instanceof Error ? cause.message : String(cause)})`,
|
|
28
|
+
);
|
|
29
|
+
this.name = "OllamaUnavailableError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Raised when ollama responds but with a non-2xx status (e.g. model missing). */
|
|
34
|
+
export class OllamaResponseError extends Error {
|
|
35
|
+
status: number;
|
|
36
|
+
constructor(status: number, detail: string) {
|
|
37
|
+
super(`ollama /api/chat failed (HTTP ${status}): ${detail}`);
|
|
38
|
+
this.name = "OllamaResponseError";
|
|
39
|
+
this.status = status;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Default local ollama endpoint (same default as the ollama CLI/SDK). */
|
|
44
|
+
export const DEFAULT_OLLAMA_URL = "http://127.0.0.1:11434";
|
|
45
|
+
/** Default model — matches `viber/llm.py` DEFAULT_MODEL. */
|
|
46
|
+
export const DEFAULT_GEMMA_MODEL = "gemma4:e4b";
|
|
47
|
+
|
|
48
|
+
export interface OllamaChatOptions {
|
|
49
|
+
baseUrl?: string;
|
|
50
|
+
model?: string;
|
|
51
|
+
/** Sampling temperature (parity with standalone_ai.py: 0.7). */
|
|
52
|
+
temperature?: number;
|
|
53
|
+
/** Abort the request if the stream is torn down mid-generation. */
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
/** Injectable fetch for tests (defaults to global fetch). */
|
|
56
|
+
fetchImpl?: typeof fetch;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Shape of the (non-streaming) ollama /api/chat response we consume. */
|
|
60
|
+
interface OllamaChatResponse {
|
|
61
|
+
message?: { role?: string; content?: string };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Send a chat completion to ollama and return the assistant's text.
|
|
66
|
+
*
|
|
67
|
+
* `stream: false` so we get one JSON object (no SSE parsing). A network failure
|
|
68
|
+
* (ECONNREFUSED when the daemon is down) maps to OllamaUnavailableError; a
|
|
69
|
+
* non-2xx maps to OllamaResponseError — the bridge turns both into a clean Viber
|
|
70
|
+
* reply rather than crashing.
|
|
71
|
+
*/
|
|
72
|
+
export async function ollamaChat(
|
|
73
|
+
messages: ChatMessage[],
|
|
74
|
+
opts: OllamaChatOptions = {},
|
|
75
|
+
): Promise<string> {
|
|
76
|
+
const baseUrl = opts.baseUrl ?? DEFAULT_OLLAMA_URL;
|
|
77
|
+
const model = opts.model ?? DEFAULT_GEMMA_MODEL;
|
|
78
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
79
|
+
|
|
80
|
+
let resp: Response;
|
|
81
|
+
try {
|
|
82
|
+
resp = await doFetch(`${baseUrl}/api/chat`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "content-type": "application/json" },
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
model,
|
|
87
|
+
messages,
|
|
88
|
+
stream: false,
|
|
89
|
+
options: { temperature: opts.temperature ?? 0.7 },
|
|
90
|
+
}),
|
|
91
|
+
signal: opts.signal,
|
|
92
|
+
});
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// AbortError must propagate (deliberate teardown), not be masked as "down".
|
|
95
|
+
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
96
|
+
throw new OllamaUnavailableError(baseUrl, err);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!resp.ok) {
|
|
100
|
+
const detail = await resp.text().catch(() => "");
|
|
101
|
+
throw new OllamaResponseError(resp.status, detail);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const data = (await resp.json()) as OllamaChatResponse;
|
|
105
|
+
return (data.message?.content ?? "").trim();
|
|
106
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parent-liveness watchdog (#298) — exit when the agent that spawned us dies.
|
|
3
|
+
*
|
|
4
|
+
* WHY: a viber-channel is an MCP server spawned over stdio by Claude Code. When
|
|
5
|
+
* Claude Code dies, the channel SHOULD exit so it stops holding its SSE streams
|
|
6
|
+
* open and stops sending presence heartbeats — otherwise it becomes a zombie
|
|
7
|
+
* that still shows "online" while no agent answers (observed live: a 4-day-old
|
|
8
|
+
* orphaned channel process).
|
|
9
|
+
*
|
|
10
|
+
* The existing `process.stdin.on("end")` handler is the intended detector, but it
|
|
11
|
+
* is unreliable on Windows: when the parent is killed via TerminateProcess (crash
|
|
12
|
+
* / Task Manager) through a `cmd.exe → bun` or `bunx` wrapper chain, the stdin
|
|
13
|
+
* pipe EOF is not always propagated to the child. The MCP SDK is no help either —
|
|
14
|
+
* `StdioServerTransport` only listens to stdin `data`/`error`, never `end`/`close`,
|
|
15
|
+
* so `server.onclose` never fires on parent death.
|
|
16
|
+
*
|
|
17
|
+
* This watchdog is the reliable backstop: poll the parent PID; the instant it is
|
|
18
|
+
* gone, exit. It does NOT guess from inactivity (no false positives while an
|
|
19
|
+
* agent merely thinks for a long time) — it keys on the OS truth "is my parent
|
|
20
|
+
* still alive". `signal 0` probes existence without delivering a signal.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { execSync } from "node:child_process";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Shim processes that sit BETWEEN the real agent (e.g. claude.exe) and us in the
|
|
27
|
+
* spawn tree. Claude Code spawns `cmd.exe /c bun …` (or `… bunx …`), so our
|
|
28
|
+
* immediate parent is usually a shell/runtime shim — watching it is useless
|
|
29
|
+
* because killing Claude leaves the shim orphaned but alive. We walk up past
|
|
30
|
+
* these to find the real agent PID.
|
|
31
|
+
*/
|
|
32
|
+
export const SHIM_PROCESS_NAMES = new Set([
|
|
33
|
+
"cmd.exe",
|
|
34
|
+
"conhost.exe",
|
|
35
|
+
"bun.exe",
|
|
36
|
+
"bunx.exe",
|
|
37
|
+
"powershell.exe",
|
|
38
|
+
"pwsh.exe",
|
|
39
|
+
"sh.exe",
|
|
40
|
+
"bash.exe",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
export interface ProcInfo {
|
|
44
|
+
ppid: number;
|
|
45
|
+
name: string;
|
|
46
|
+
/** CIM CreationDate string — part of the (pid, creationDate) identity that
|
|
47
|
+
* survives PID reuse (#298 P2b). Empty when unknown (e.g. injected in tests). */
|
|
48
|
+
creationDate?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Identity of the watched agent — pid + the fields that defeat PID reuse. */
|
|
52
|
+
export interface AgentIdentity {
|
|
53
|
+
pid: number;
|
|
54
|
+
name: string;
|
|
55
|
+
creationDate: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Walk up the ancestry from `startPid`, skipping shim processes, and return the
|
|
60
|
+
* first "real" (non-shim) ancestor PID — the agent that owns this channel.
|
|
61
|
+
*
|
|
62
|
+
* Returns null when no non-shim ancestor is reachable (the walk hits a pid that
|
|
63
|
+
* is absent from the table, i.e. the agent is already gone). #298 P2a: we do NOT
|
|
64
|
+
* fall back to the immediate shim parent — a shim (cmd.exe/bun) outlives a killed
|
|
65
|
+
* agent, so watching it would never fire and the zombie would survive. Better to
|
|
66
|
+
* return null (no watch) than to watch the wrong, longer-lived process. Pure: the
|
|
67
|
+
* process table is injected for testability.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveAgentPid(
|
|
70
|
+
startPid: number,
|
|
71
|
+
table: Map<number, ProcInfo>,
|
|
72
|
+
): number | null {
|
|
73
|
+
const start = table.get(startPid);
|
|
74
|
+
if (!start) return null;
|
|
75
|
+
let cur = start.ppid;
|
|
76
|
+
const seen = new Set<number>([startPid]);
|
|
77
|
+
while (cur && cur > 1 && !seen.has(cur)) {
|
|
78
|
+
seen.add(cur);
|
|
79
|
+
const info = table.get(cur);
|
|
80
|
+
if (!info) break; // chain breaks → agent not present → caller no-ops
|
|
81
|
+
if (!SHIM_PROCESS_NAMES.has(info.name.toLowerCase())) {
|
|
82
|
+
return cur; // first non-shim ancestor = the agent
|
|
83
|
+
}
|
|
84
|
+
cur = info.ppid;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read the Windows process table (pid → {ppid, name, creationDate}) via one CIM
|
|
91
|
+
* query, or null on any failure / non-Windows. Time-bounded so a slow WMI never
|
|
92
|
+
* stalls startup.
|
|
93
|
+
*/
|
|
94
|
+
export function readWindowsProcessTable(): Map<number, ProcInfo> | null {
|
|
95
|
+
if (process.platform !== "win32") return null;
|
|
96
|
+
try {
|
|
97
|
+
const out = execSync(
|
|
98
|
+
'powershell -NoProfile -Command "Get-CimInstance Win32_Process | ' +
|
|
99
|
+
'Select-Object ProcessId,ParentProcessId,Name,CreationDate | ConvertTo-Csv -NoTypeInformation"',
|
|
100
|
+
{ encoding: "utf8", timeout: 4000, stdio: ["ignore", "pipe", "ignore"] },
|
|
101
|
+
);
|
|
102
|
+
const table = new Map<number, ProcInfo>();
|
|
103
|
+
for (const line of out.split(/\r?\n/)) {
|
|
104
|
+
// CSV: "ProcessId","ParentProcessId","Name","CreationDate"
|
|
105
|
+
const m = line.match(/^"?(\d+)"?,"?(\d+)"?,"?(.*?)"?,"?(.*?)"?$/);
|
|
106
|
+
if (!m) continue;
|
|
107
|
+
table.set(Number(m[1]), { ppid: Number(m[2]), name: m[3], creationDate: m[4] });
|
|
108
|
+
}
|
|
109
|
+
return table.size > 0 ? table : null;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read one process's identity (name + creationDate) by PID via a filtered CIM
|
|
117
|
+
* query, or null if it doesn't exist. Used per-poll to defeat PID reuse (#298
|
|
118
|
+
* P2b): a recycled PID will have a different name/creationDate than the agent we
|
|
119
|
+
* resolved at startup, so we treat it as gone. Fast (single object) + bounded.
|
|
120
|
+
*/
|
|
121
|
+
export function readProcessIdentity(
|
|
122
|
+
pid: number,
|
|
123
|
+
): { name: string; creationDate: string } | null {
|
|
124
|
+
if (process.platform !== "win32") return null;
|
|
125
|
+
try {
|
|
126
|
+
const out = execSync(
|
|
127
|
+
`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}' | ` +
|
|
128
|
+
'Select-Object Name,CreationDate | ConvertTo-Csv -NoTypeInformation"',
|
|
129
|
+
{ encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] },
|
|
130
|
+
);
|
|
131
|
+
for (const line of out.split(/\r?\n/)) {
|
|
132
|
+
const m = line.match(/^"(.*?)","(.*?)"$/);
|
|
133
|
+
if (!m || m[1] === "Name") continue; // skip header
|
|
134
|
+
return { name: m[1], creationDate: m[2] };
|
|
135
|
+
}
|
|
136
|
+
return null; // no row → pid not found
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface ParentWatchdogOptions {
|
|
143
|
+
/**
|
|
144
|
+
* Explicit PID to watch (skips the ancestor walk). Mainly for tests; production
|
|
145
|
+
* resolves the agent ancestor from the process table.
|
|
146
|
+
*/
|
|
147
|
+
ppid?: number;
|
|
148
|
+
/** Expected identity for PID-reuse defense; paired with an explicit `ppid`. */
|
|
149
|
+
identity?: AgentIdentity;
|
|
150
|
+
/** Poll cadence (ms). Default 5000. */
|
|
151
|
+
intervalMs?: number;
|
|
152
|
+
/**
|
|
153
|
+
* Liveness+identity probe: true iff the resolved agent is STILL the same live
|
|
154
|
+
* process. Default validates name+creationDate via a filtered CIM query (falls
|
|
155
|
+
* back to bare PID existence if identity is unknown). Injectable for tests.
|
|
156
|
+
*/
|
|
157
|
+
isAlive?: (id: AgentIdentity) => boolean;
|
|
158
|
+
/** Called when the parent is gone (before exit). Defaults to process.exit(0). */
|
|
159
|
+
onParentGone?: () => void;
|
|
160
|
+
log?: (line: string) => void;
|
|
161
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
162
|
+
clearTimer?: (handle: unknown) => void;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface ParentWatchdogHandle {
|
|
166
|
+
stop: () => void;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const DEFAULT_INTERVAL_MS = 5000;
|
|
170
|
+
|
|
171
|
+
/** True iff `pid` currently exists. `kill(pid, 0)` throws ESRCH when it doesn't. */
|
|
172
|
+
export function pidIsAlive(pid: number): boolean {
|
|
173
|
+
try {
|
|
174
|
+
process.kill(pid, 0);
|
|
175
|
+
return true;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// EPERM = exists but not ours to signal → still alive. ESRCH = gone.
|
|
178
|
+
return (err as NodeJS.ErrnoException)?.code === "EPERM";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Default probe: the agent is alive iff a process with its PID exists AND matches
|
|
184
|
+
* the captured identity (name + creationDate). The identity check is what defeats
|
|
185
|
+
* PID reuse (#298 P2b) — a recycled PID has a different creationDate/name. If we
|
|
186
|
+
* never captured a creationDate (non-win32 / unknown), fall back to bare PID
|
|
187
|
+
* existence so we don't false-positive a still-running agent.
|
|
188
|
+
*/
|
|
189
|
+
function defaultIsAlive(id: AgentIdentity): boolean {
|
|
190
|
+
if (!id.creationDate) return pidIsAlive(id.pid);
|
|
191
|
+
const cur = readProcessIdentity(id.pid);
|
|
192
|
+
if (cur === null) {
|
|
193
|
+
// CIM said "no such pid". Trust it only if the pid is genuinely gone; if the
|
|
194
|
+
// query itself failed transiently, pidIsAlive still reports existence and we
|
|
195
|
+
// keep watching (fail-safe: never exit on a flaky query).
|
|
196
|
+
return pidIsAlive(id.pid);
|
|
197
|
+
}
|
|
198
|
+
return cur.creationDate === id.creationDate && cur.name === id.name;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Start watching the agent process. Returns a handle whose `stop()` cancels it.
|
|
203
|
+
*
|
|
204
|
+
* No-op (returns an inert handle) when no agent ancestor can be resolved — e.g.
|
|
205
|
+
* the agent is already gone at startup (resolveAgentPid returns null rather than
|
|
206
|
+
* a surviving shim, #298 P2a) or on non-Windows. We must never exit spuriously,
|
|
207
|
+
* so an unresolvable agent means "don't watch" (the stdin-EOF handler still
|
|
208
|
+
* covers the clean-exit path).
|
|
209
|
+
*/
|
|
210
|
+
export function startParentWatchdog(
|
|
211
|
+
opts: ParentWatchdogOptions = {},
|
|
212
|
+
): ParentWatchdogHandle {
|
|
213
|
+
const log = opts.log ?? ((line: string) => process.stderr.write(line));
|
|
214
|
+
|
|
215
|
+
// Resolve the agent to watch + its identity. Explicit ppid (tests) wins;
|
|
216
|
+
// otherwise walk the process table past shims to the real agent (claude.exe).
|
|
217
|
+
let identity: AgentIdentity | null = null;
|
|
218
|
+
if (opts.ppid !== undefined && opts.ppid > 1) {
|
|
219
|
+
identity = opts.identity ?? { pid: opts.ppid, name: "", creationDate: "" };
|
|
220
|
+
} else if (opts.ppid === undefined) {
|
|
221
|
+
const table = readWindowsProcessTable();
|
|
222
|
+
const resolved = table ? resolveAgentPid(process.pid, table) : null;
|
|
223
|
+
if (resolved && table) {
|
|
224
|
+
const info = table.get(resolved);
|
|
225
|
+
identity = {
|
|
226
|
+
pid: resolved,
|
|
227
|
+
name: info?.name ?? "",
|
|
228
|
+
creationDate: info?.creationDate ?? "",
|
|
229
|
+
};
|
|
230
|
+
log(`[viber-channel] parent watchdog watching agent pid ${resolved} (${identity.name})\n`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!identity) {
|
|
235
|
+
log("[viber-channel] parent watchdog disabled (no resolvable agent ancestor)\n");
|
|
236
|
+
return { stop: () => {} };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const watched = identity;
|
|
240
|
+
const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
241
|
+
const isAlive = opts.isAlive ?? defaultIsAlive;
|
|
242
|
+
const onParentGone =
|
|
243
|
+
opts.onParentGone ??
|
|
244
|
+
(() => {
|
|
245
|
+
process.exit(0);
|
|
246
|
+
});
|
|
247
|
+
const setTimer =
|
|
248
|
+
opts.setTimer ?? ((fn: () => void, ms: number) => setInterval(fn, ms) as unknown);
|
|
249
|
+
const clearTimer =
|
|
250
|
+
opts.clearTimer ??
|
|
251
|
+
((h: unknown) => clearInterval(h as ReturnType<typeof setInterval>));
|
|
252
|
+
|
|
253
|
+
let stopped = false;
|
|
254
|
+
const handle = setTimer(() => {
|
|
255
|
+
if (stopped) return;
|
|
256
|
+
if (!isAlive(watched)) {
|
|
257
|
+
log(`[viber-channel] agent process ${watched.pid} is gone — exiting to avoid a zombie channel\n`);
|
|
258
|
+
stopped = true;
|
|
259
|
+
clearTimer(handle);
|
|
260
|
+
onParentGone();
|
|
261
|
+
}
|
|
262
|
+
}, intervalMs);
|
|
263
|
+
|
|
264
|
+
// The watchdog itself must not keep the process alive.
|
|
265
|
+
if (handle && typeof (handle as { unref?: () => void }).unref === "function") {
|
|
266
|
+
(handle as { unref: () => void }).unref();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
stop: () => {
|
|
271
|
+
if (stopped) return;
|
|
272
|
+
stopped = true;
|
|
273
|
+
clearTimer(handle);
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
package/lib/peers.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* peers.ts — HTTP client for the agent-facing endpoints (#288):
|
|
3
|
+
* GET /api/instances/peers — discover the other agents of this project
|
|
4
|
+
* POST /api/instances/dms — open-or-create a 1:1 DM with a peer
|
|
5
|
+
*
|
|
6
|
+
* Pure functions: no state, no MCP. The CallTool handlers in viber-channel.ts
|
|
7
|
+
* call these and map the result to MCP tool results. Auth is the caller's
|
|
8
|
+
* durable instance_token (Bearer) — the server scopes everything to that
|
|
9
|
+
* instance's project + owner.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { cfAccessHeaders } from "./cfAccess.js";
|
|
13
|
+
|
|
14
|
+
/** A peer agent as returned by GET /api/instances/peers. */
|
|
15
|
+
export interface PeerView {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string | null;
|
|
18
|
+
kind: string;
|
|
19
|
+
online: boolean;
|
|
20
|
+
last_seen: number | null;
|
|
21
|
+
active_conversation_id: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* List the OTHER active instances of this agent's project (self excluded).
|
|
26
|
+
* Throws on a non-2xx response so the caller can surface a clear tool error.
|
|
27
|
+
*/
|
|
28
|
+
export async function listPeers(
|
|
29
|
+
baseUrl: string,
|
|
30
|
+
instanceToken: string,
|
|
31
|
+
): Promise<PeerView[]> {
|
|
32
|
+
const resp = await fetch(`${baseUrl}/api/instances/peers`, {
|
|
33
|
+
method: "GET",
|
|
34
|
+
headers: { Authorization: `Bearer ${instanceToken}`, ...cfAccessHeaders() },
|
|
35
|
+
});
|
|
36
|
+
if (!resp.ok) {
|
|
37
|
+
let detail = `HTTP ${resp.status}`;
|
|
38
|
+
try {
|
|
39
|
+
const json = (await resp.json()) as { detail?: string };
|
|
40
|
+
if (typeof json.detail === "string") detail = `HTTP ${resp.status}: ${json.detail}`;
|
|
41
|
+
} catch {
|
|
42
|
+
/* non-JSON body */
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`listPeers failed: ${detail}`);
|
|
45
|
+
}
|
|
46
|
+
const body = (await resp.json()) as { instances?: PeerView[] };
|
|
47
|
+
return Array.isArray(body.instances) ? body.instances : [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Result of opening (or reusing) a DM with a peer — carries the caller's own
|
|
51
|
+
* membership conversation_token so it can post into the DM. */
|
|
52
|
+
export interface OpenDmResult {
|
|
53
|
+
conversation_id: string;
|
|
54
|
+
conversation_token: string;
|
|
55
|
+
expires_at: number;
|
|
56
|
+
invited: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type OpenDmOutcome =
|
|
60
|
+
| { ok: true; dm: OpenDmResult }
|
|
61
|
+
| { ok: false; detail: string; status: number };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Open-or-create a 1:1 DM with `peerInstanceId` (POST /api/instances/dms).
|
|
65
|
+
* Returns a structured outcome rather than throwing on the expected 4xx cases
|
|
66
|
+
* (peer offline / not found) so the tool can surface a clear message to the
|
|
67
|
+
* model; network/parse failures throw.
|
|
68
|
+
*/
|
|
69
|
+
export async function openDm(
|
|
70
|
+
baseUrl: string,
|
|
71
|
+
instanceToken: string,
|
|
72
|
+
peerInstanceId: string,
|
|
73
|
+
): Promise<OpenDmOutcome> {
|
|
74
|
+
const resp = await fetch(`${baseUrl}/api/instances/dms`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
Authorization: `Bearer ${instanceToken}`,
|
|
78
|
+
"Content-Type": "application/json",
|
|
79
|
+
...cfAccessHeaders(),
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify({ peer_instance_id: peerInstanceId }),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!resp.ok) {
|
|
85
|
+
let detail = `HTTP ${resp.status}`;
|
|
86
|
+
try {
|
|
87
|
+
const json = (await resp.json()) as { detail?: string };
|
|
88
|
+
if (typeof json.detail === "string") detail = json.detail;
|
|
89
|
+
} catch {
|
|
90
|
+
/* non-JSON body */
|
|
91
|
+
}
|
|
92
|
+
return { ok: false, detail, status: resp.status };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const body = (await resp.json()) as {
|
|
96
|
+
conversation?: { id?: string };
|
|
97
|
+
conversation_token?: string;
|
|
98
|
+
expires_at?: number;
|
|
99
|
+
invited?: boolean;
|
|
100
|
+
};
|
|
101
|
+
if (!body.conversation?.id || typeof body.conversation_token !== "string") {
|
|
102
|
+
throw new Error("openDm: malformed response (missing conversation id or token)");
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
dm: {
|
|
107
|
+
conversation_id: body.conversation.id,
|
|
108
|
+
conversation_token: body.conversation_token,
|
|
109
|
+
expires_at: typeof body.expires_at === "number" ? body.expires_at : 0,
|
|
110
|
+
invited: body.invited === true,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
package/lib/supervisor.ts
CHANGED
|
@@ -51,6 +51,17 @@ export interface AgentSpec {
|
|
|
51
51
|
tier: string;
|
|
52
52
|
args: string[];
|
|
53
53
|
env?: Record<string, string>;
|
|
54
|
+
/**
|
|
55
|
+
* Optional operator-assigned role (#309 step-17). Untrusted free text; the
|
|
56
|
+
* bridge sanitizes it before injecting it into the runtime instructions.
|
|
57
|
+
*/
|
|
58
|
+
role?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Whether `agentId` was set EXPLICITLY (vs an auto-generated id like
|
|
61
|
+
* `agent-1`). Only an explicit id is injected as the agent's name — auto ids
|
|
62
|
+
* look like names but aren't, so the bridge must not claim them (#309).
|
|
63
|
+
*/
|
|
64
|
+
nameExplicit?: boolean;
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
/** Minimal child-process surface the supervisor depends on (injectable for tests). */
|
package/lib/supervisor_config.ts
CHANGED
|
@@ -32,6 +32,13 @@ interface RawAgent {
|
|
|
32
32
|
args?: unknown;
|
|
33
33
|
count?: unknown;
|
|
34
34
|
env?: unknown;
|
|
35
|
+
role?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseRoleField(raw: unknown): string | undefined {
|
|
39
|
+
if (raw === undefined || raw === null) return undefined;
|
|
40
|
+
if (typeof raw !== "string") throw new Error("agent.role must be a string");
|
|
41
|
+
return raw;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
function parseArgsField(raw: unknown): string[] {
|
|
@@ -98,6 +105,11 @@ export function parseSupervisorConfig(raw: unknown): AgentSpec[] {
|
|
|
98
105
|
const args = parseArgsField(entry.args);
|
|
99
106
|
const env = parseEnvField(entry.env);
|
|
100
107
|
const count = parseCount(entry.count);
|
|
108
|
+
const role = parseRoleField(entry.role);
|
|
109
|
+
// #309: a config `id` is operator-chosen → an explicit name (the
|
|
110
|
+
// count-suffix below keeps it explicit, e.g. `rev-1`). Auto `agent-N`
|
|
111
|
+
// ids are NOT explicit and must not be injected as the agent's name.
|
|
112
|
+
const nameExplicit = explicitId !== undefined;
|
|
101
113
|
|
|
102
114
|
for (let i = 0; i < count; i++) {
|
|
103
115
|
// count > 1 suffixes the explicit id (rev-1, rev-2…); a single agent keeps
|
|
@@ -113,7 +125,14 @@ export function parseSupervisorConfig(raw: unknown): AgentSpec[] {
|
|
|
113
125
|
seen.add(id);
|
|
114
126
|
// Independent args array per spec so a later mutation can't bleed across
|
|
115
127
|
// count-expanded siblings.
|
|
116
|
-
specs.push({
|
|
128
|
+
specs.push({
|
|
129
|
+
agentId: id,
|
|
130
|
+
tier: entry.tier,
|
|
131
|
+
args: [...args],
|
|
132
|
+
...(env ? { env } : {}),
|
|
133
|
+
...(role !== undefined ? { role } : {}),
|
|
134
|
+
...(nameExplicit ? { nameExplicit: true } : {}),
|
|
135
|
+
});
|
|
117
136
|
}
|
|
118
137
|
}
|
|
119
138
|
|
package/package.json
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viber-channel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"viber-channel": "./viber-channel.ts",
|
|
8
8
|
"viber-codex-bridge": "./viber-codex-bridge.ts",
|
|
9
|
+
"viber-gemma-bridge": "./viber-gemma-bridge.ts",
|
|
9
10
|
"viber-codex-supervisor": "./viber-codex-supervisor.ts"
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"viber-channel.ts",
|
|
13
14
|
"viber-codex-bridge.ts",
|
|
15
|
+
"viber-gemma-bridge.ts",
|
|
14
16
|
"viber-codex-supervisor.ts",
|
|
15
17
|
"lib/",
|
|
16
18
|
"README.md"
|