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/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(baseUrl, auth.project_id, auth.project_token, fingerprint, label);
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(baseUrl, auth.project_id, auth.project_token, fingerprint);
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). */
@@ -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({ agentId: id, tier: entry.tier, args: [...args], ...(env ? { env } : {}) });
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.6.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"