viber-channel 0.5.2 → 0.6.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/lockfile.ts CHANGED
@@ -1,25 +1,50 @@
1
1
  /**
2
- * lockfile.ts — derive the channel's lock file path from VIBER_BASE_URL.
2
+ * lockfile.ts — derive the channel's lock file path from VIBER_BASE_URL, the
3
+ * client fingerprint, and an optional per-launch session id.
3
4
  *
4
5
  * The lock prevents two instances of the channel from racing on stdin when
5
- * Claude Code accidentally spawns duplicates. But two channels targeting
6
+ * Claude Code accidentally spawns duplicates. Two channels targeting
6
7
  * *different* backends (e.g. staging + dev) are legitimately different
7
- * processes — they shouldn't share a lock.
8
+ * processes — they shouldn't share a lock; namespacing by base URL lets
9
+ * them coexist.
8
10
  *
9
- * Namespacing the lock by base URL lets them coexist.
11
+ * #267 adds the project axis: the lock is namespaced by `client_fingerprint`
12
+ * (deterministic per machine + folder, from .viber/auth.json). Two *different*
13
+ * projects on the same backend therefore never share a lock — this is the only
14
+ * axis that isolates them for a normal bunx client, where sessionId is always
15
+ * "" (the #259 sessionId axis below provides no isolation there).
16
+ *
17
+ * #259 adds the per-instance axis: when the launcher (e.g. viber-dev.ps1)
18
+ * supplies a per-launch session id via VIBER_CHANNEL_SESSION_ID, the lock is
19
+ * additionally namespaced by it, so two concurrent launches of the *same*
20
+ * project each get their own lock. Channel respawns within one launch inherit
21
+ * the same env var and reuse the same lock — accidental duplicates are still
22
+ * blocked.
10
23
  */
11
24
  import { createHash } from "node:crypto";
12
25
  import { join } from "node:path";
13
26
 
14
27
  /**
15
- * Return the lock file path for a given base URL, under `lockDir`.
28
+ * Return the lock file path for a given base URL, fingerprint, and session id,
29
+ * under `lockDir`.
16
30
  *
17
31
  * The filename is `channel-{8-hex}.lock` where the hex is a stable SHA-256
18
- * prefix of the base URL. Two callers with the same base URL get the same
19
- * path (same process collision still detected); different base URLs get
20
- * different paths (no false collision).
32
+ * prefix of `${baseUrl}\n${fingerprint}\n${sessionId}` (newline-separated so
33
+ * distinct concatenations cannot collide).
34
+ *
35
+ * When `sessionId === ""` (external bunx clients), the hash is stable on
36
+ * `${baseUrl}\n${fingerprint}\n` — so each project (distinct fingerprint)
37
+ * still converges on its own consistent path.
21
38
  */
22
- export function lockFilePath(baseUrl: string, lockDir: string): string {
23
- const suffix = createHash("sha256").update(baseUrl).digest("hex").slice(0, 8);
39
+ export function lockFilePath(
40
+ baseUrl: string,
41
+ fingerprint: string,
42
+ sessionId: string,
43
+ lockDir: string,
44
+ ): string {
45
+ const suffix = createHash("sha256")
46
+ .update(`${baseUrl}\n${fingerprint}\n${sessionId}`)
47
+ .digest("hex")
48
+ .slice(0, 8);
24
49
  return join(lockDir, `channel-${suffix}.lock`);
25
50
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * MessageDedup (#254 follow-up) — drops the persisted echo of a user transcript.
3
+ *
4
+ * When a web UI is open, Python delivers the user's voice transcript to the
5
+ * channel TWICE: an ephemeral copy (`id: null`) the instant it is transcribed
6
+ * (the conversation event-bus path from #221), then the persisted copy (real
7
+ * `id`) once the browser saves it to the DB and the Worker republishes it.
8
+ * Both carry identical content, so Claude sees the utterance twice.
9
+ *
10
+ * This tracks recently-seen ephemeral (id-less) messages and reports a later
11
+ * id-bearing message with the same content as a duplicate. The channel forwards
12
+ * each utterance exactly once:
13
+ * - ephemeral message → recorded, forwarded
14
+ * - persisted echo of it → dropped
15
+ * - persisted message with no prior ephemeral (assistant/ai/channel) → forwarded
16
+ * - a genuine repeat (new ephemeral of the same text) → forwarded
17
+ *
18
+ * Content-only, time-bounded matching: a persisted message is only dropped if an
19
+ * ephemeral with the same trimmed content was seen within `ttlMs`.
20
+ */
21
+ export class MessageDedup {
22
+ private readonly recent = new Map<string, number>();
23
+
24
+ constructor(private readonly ttlMs: number = 15_000) {}
25
+
26
+ /**
27
+ * Records ephemeral (id-less) messages; returns `true` for a persisted
28
+ * (id-bearing) message that echoes a recent ephemeral one — i.e. drop it.
29
+ */
30
+ isDuplicate(msg: { id?: string | null; content: string }, now: number = Date.now()): boolean {
31
+ this.prune(now);
32
+ const key = msg.content.trim();
33
+ if (!key) return false;
34
+ const hasId = typeof msg.id === "string" && msg.id.length > 0;
35
+ if (!hasId) {
36
+ this.recent.set(key, now);
37
+ return false;
38
+ }
39
+ return this.recent.has(key);
40
+ }
41
+
42
+ private prune(now: number): void {
43
+ for (const [key, ts] of this.recent) {
44
+ if (now - ts > this.ttlMs) this.recent.delete(key);
45
+ }
46
+ }
47
+ }
package/lib/messages.ts CHANGED
@@ -71,6 +71,30 @@ export function parseArtifact(raw: unknown): ParseArtifactResult {
71
71
  return { ok: true, artifact: { content, format: rawArtifact.format as ArtifactFormat } };
72
72
  }
73
73
 
74
+ /**
75
+ * GET /api/conversations/:id/messages with a conversation_token (multi-auth).
76
+ *
77
+ * Used by the bridge to CATCH UP on messages it missed while its SSE was
78
+ * disconnected (#280 step-06): the conversation SSE has no replay, so on each
79
+ * reconnect the bridge re-reads the message list and processes any it hasn't
80
+ * seen. Returns the raw message array (ascending id) or throws on failure /
81
+ * `ConversationTokenExpiredError` on 401 so the caller can refresh + retry.
82
+ */
83
+ export async function fetchMessages(
84
+ baseUrl: string,
85
+ conversationId: string,
86
+ conversationToken: string,
87
+ ): Promise<Array<Record<string, unknown>>> {
88
+ const resp = await fetch(`${baseUrl}/api/conversations/${conversationId}/messages`, {
89
+ method: "GET",
90
+ headers: { Authorization: `Bearer ${conversationToken}`, ...cfAccessHeaders() },
91
+ });
92
+ if (resp.status === 401) throw new ConversationTokenExpiredError();
93
+ if (!resp.ok) throw new Error(`fetchMessages failed: HTTP ${resp.status}`);
94
+ const body = (await resp.json()) as { messages?: Array<Record<string, unknown>> };
95
+ return Array.isArray(body.messages) ? body.messages : [];
96
+ }
97
+
74
98
  export interface MessagePostSuccess {
75
99
  ok: true;
76
100
  message_id: string;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Self-echo guard: true when a message was posted BY this session's own
3
+ * instance, so it isn't surfaced back to the session that sent it.
4
+ *
5
+ * The conversation SSE stream replays every message posted to the conversation,
6
+ * including messages posted by this session via send_message. Without this
7
+ * guard, Claude would receive its own replies as inbound channel events.
8
+ */
9
+
10
+ /** True when this message was posted BY this session's own instance (self-echo). */
11
+ export function isOwnMessage(senderInstanceId: unknown, ownInstanceId: string): boolean {
12
+ return (
13
+ ownInstanceId !== "" &&
14
+ typeof senderInstanceId === "string" &&
15
+ senderInstanceId === ownInstanceId
16
+ );
17
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Startup stability gate (#257).
3
+ *
4
+ * Defers work by `delayMs` milliseconds, then reports whether the process
5
+ * is still alive (true = proceed, false = shutting down detected).
6
+ *
7
+ * A process whose stdin pipe closes during the window exits via the existing
8
+ * `end` handler BEFORE the timer resolves in practice — but the flag check
9
+ * is a defensive guard in case the timer wins the race.
10
+ */
11
+ export async function awaitStableStartup(
12
+ delayMs: number,
13
+ isShuttingDown: () => boolean,
14
+ ): Promise<boolean> {
15
+ if (delayMs > 0) {
16
+ await new Promise<void>((r) => setTimeout(r, delayMs));
17
+ }
18
+ return !isShuttingDown();
19
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Agent supervisor (#280 step-12, slice 2).
3
+ *
4
+ * Hosts MANY agents by spawning one OS process per agent and managing each one's
5
+ * lifecycle independently. This is the client-side multiplexer the control plane
6
+ * drives; agents still never talk directly (hub-and-spoke — the Viber server
7
+ * relays), so one-process-per-agent buys crash isolation for free.
8
+ *
9
+ * Design (per .claude/rules/architecture.md):
10
+ * - explicit state machine per agent (no boolean soup),
11
+ * - a per-agent state object in a registry (no scattered dicts),
12
+ * - spawn + restart scheduling injected so the lifecycle is unit-testable
13
+ * without real child processes.
14
+ *
15
+ * This module owns ONLY lifecycle (spawn / crash / restart / stop). Wiring it to a
16
+ * real bridge launch and to the control-plane UI is a later slice.
17
+ *
18
+ * Follow-up (real-spawn slice): harden against a synchronous spawn() throw — today
19
+ * spawn is injected and trusted; the real node-spawn SpawnFn should be wrapped so a
20
+ * launch failure transitions STARTING -> CRASHED and re-enters the backoff path
21
+ * instead of leaving the agent stuck in STARTING.
22
+ */
23
+
24
+ /** Lifecycle states for a single supervised agent. */
25
+ export enum AgentState {
26
+ /** Spawn requested; child not yet confirmed running. */
27
+ STARTING = "starting",
28
+ /** Child process is live. */
29
+ RUNNING = "running",
30
+ /** Child exited unexpectedly; awaiting a restart decision. */
31
+ CRASHED = "crashed",
32
+ /** Backoff window before the next spawn attempt. */
33
+ RESTARTING = "restarting",
34
+ /** Stopped on request, or crashed past the restart budget. Terminal. */
35
+ STOPPED = "stopped",
36
+ }
37
+
38
+ /** Legal transitions. Any attempt outside this map throws. */
39
+ const TRANSITIONS: Record<AgentState, readonly AgentState[]> = {
40
+ [AgentState.STARTING]: [AgentState.RUNNING, AgentState.CRASHED, AgentState.STOPPED],
41
+ [AgentState.RUNNING]: [AgentState.CRASHED, AgentState.STOPPED],
42
+ [AgentState.CRASHED]: [AgentState.RESTARTING, AgentState.STOPPED],
43
+ [AgentState.RESTARTING]: [AgentState.STARTING, AgentState.STOPPED],
44
+ [AgentState.STOPPED]: [],
45
+ };
46
+
47
+ /** What to launch for one agent. `args`/`env` are passed to the spawned bridge. */
48
+ export interface AgentSpec {
49
+ agentId: string;
50
+ /** Permission tier (step-11): "chat" | "read" | "write". Carried to the child env. */
51
+ tier: string;
52
+ args: string[];
53
+ env?: Record<string, string>;
54
+ }
55
+
56
+ /** Minimal child-process surface the supervisor depends on (injectable for tests). */
57
+ export interface ChildHandle {
58
+ readonly pid?: number;
59
+ kill(signal?: NodeJS.Signals): boolean;
60
+ /** Fires once when the process exits. */
61
+ onExit(cb: (code: number | null, signal: NodeJS.Signals | null) => void): void;
62
+ }
63
+
64
+ export type SpawnFn = (spec: AgentSpec) => ChildHandle;
65
+
66
+ /** Schedules `fn` after `ms`; returns a canceller. Injected so tests run instantly. */
67
+ export type RestartScheduler = (fn: () => void, ms: number) => () => void;
68
+
69
+ export interface SupervisorOptions {
70
+ spawn: SpawnFn;
71
+ /**
72
+ * Max consecutive crash-restarts before giving up (then the agent goes STOPPED).
73
+ * This counts RESTARTS, not spawn attempts: maxRestarts=N allows N restarts, i.e.
74
+ * N+1 total spawns, before stopping. Default 5.
75
+ */
76
+ maxRestarts?: number;
77
+ /** Base backoff (ms) for the first restart; doubles each consecutive crash. Default 1000. */
78
+ baseBackoffMs?: number;
79
+ /** Backoff ceiling (ms). Default 30000. */
80
+ maxBackoffMs?: number;
81
+ /**
82
+ * How long an agent must stay RUNNING before its crash counter resets to 0
83
+ * (default 10000). Without this, a fast crash-loop would reset the counter on
84
+ * every optimistic RUNNING and never escalate backoff or hit maxRestarts.
85
+ */
86
+ stabilityMs?: number;
87
+ /** Restart scheduler (default setTimeout/clearTimeout). */
88
+ schedule?: RestartScheduler;
89
+ /** Optional log sink (default: stderr). */
90
+ log?: (line: string) => void;
91
+ }
92
+
93
+ /** Per-agent state object held in the registry. */
94
+ class AgentProcess {
95
+ state: AgentState = AgentState.STARTING;
96
+ child: ChildHandle | null = null;
97
+ /** Consecutive crash-restarts; reset to 0 once the agent stays up `stabilityMs`. */
98
+ restarts = 0;
99
+ cancelBackoff: (() => void) | null = null;
100
+ cancelStability: (() => void) | null = null;
101
+ /** Set when stop() was called — suppresses crash-restart. */
102
+ stopping = false;
103
+
104
+ constructor(readonly spec: AgentSpec) {}
105
+ }
106
+
107
+ /** Snapshot of one agent's status (for the control plane / `list()`). */
108
+ export interface AgentStatus {
109
+ agentId: string;
110
+ tier: string;
111
+ state: AgentState;
112
+ pid?: number;
113
+ restarts: number;
114
+ }
115
+
116
+ const defaultSchedule: RestartScheduler = (fn, ms) => {
117
+ const t = setTimeout(fn, ms);
118
+ return () => clearTimeout(t);
119
+ };
120
+
121
+ export class Supervisor {
122
+ private readonly registry = new Map<string, AgentProcess>();
123
+ private readonly spawn: SpawnFn;
124
+ private readonly maxRestarts: number;
125
+ private readonly baseBackoffMs: number;
126
+ private readonly maxBackoffMs: number;
127
+ private readonly stabilityMs: number;
128
+ private readonly schedule: RestartScheduler;
129
+ private readonly log: (line: string) => void;
130
+
131
+ constructor(opts: SupervisorOptions) {
132
+ this.spawn = opts.spawn;
133
+ this.maxRestarts = opts.maxRestarts ?? 5;
134
+ this.baseBackoffMs = opts.baseBackoffMs ?? 1000;
135
+ this.maxBackoffMs = opts.maxBackoffMs ?? 30_000;
136
+ this.stabilityMs = opts.stabilityMs ?? 10_000;
137
+ this.schedule = opts.schedule ?? defaultSchedule;
138
+ this.log = opts.log ?? ((line) => process.stderr.write(`${line}\n`));
139
+ }
140
+
141
+ /** Start a new agent. Throws if an agent with this id already exists (not STOPPED). */
142
+ start(spec: AgentSpec): void {
143
+ const existing = this.registry.get(spec.agentId);
144
+ if (existing && existing.state !== AgentState.STOPPED) {
145
+ throw new Error(`agent ${spec.agentId} already supervised (${existing.state})`);
146
+ }
147
+ const agent = new AgentProcess(spec);
148
+ this.registry.set(spec.agentId, agent);
149
+ this.spawnAgent(agent);
150
+ }
151
+
152
+ /** Stop an agent: kill the child, mark STOPPED, suppress restart. No-op if unknown. */
153
+ stop(agentId: string): void {
154
+ const agent = this.registry.get(agentId);
155
+ if (!agent || agent.state === AgentState.STOPPED) return;
156
+ agent.stopping = true;
157
+ agent.cancelBackoff?.();
158
+ agent.cancelBackoff = null;
159
+ agent.cancelStability?.();
160
+ agent.cancelStability = null;
161
+ agent.child?.kill("SIGTERM");
162
+ this.transition(agent, AgentState.STOPPED);
163
+ }
164
+
165
+ /**
166
+ * Restart an agent now (manual): stop, then start fresh with the same spec.
167
+ * Resets the crash counter to 0 (a deliberate restart is a clean slate). Works
168
+ * even on a STOPPED agent (relaunches it); no-op only for an unknown id.
169
+ */
170
+ restart(agentId: string): void {
171
+ const agent = this.registry.get(agentId);
172
+ if (!agent) return;
173
+ const spec = agent.spec;
174
+ this.stop(agentId);
175
+ this.registry.delete(agentId);
176
+ this.start(spec);
177
+ }
178
+
179
+ /** Status snapshot of every supervised agent. */
180
+ list(): AgentStatus[] {
181
+ return [...this.registry.values()].map((a) => ({
182
+ agentId: a.spec.agentId,
183
+ tier: a.spec.tier,
184
+ state: a.state,
185
+ pid: a.child?.pid,
186
+ restarts: a.restarts,
187
+ }));
188
+ }
189
+
190
+ /** Stop every agent (e.g. supervisor shutdown). */
191
+ stopAll(): void {
192
+ for (const id of this.registry.keys()) this.stop(id);
193
+ }
194
+
195
+ // ── internals ────────────────────────────────────────────────────────────
196
+
197
+ private spawnAgent(agent: AgentProcess): void {
198
+ if (agent.state === AgentState.RESTARTING) {
199
+ this.transition(agent, AgentState.STARTING);
200
+ }
201
+ agent.stopping = false;
202
+ let child: ChildHandle;
203
+ try {
204
+ child = this.spawn(agent.spec);
205
+ } catch (err) {
206
+ // A synchronous launch failure is treated like an immediate crash, so the
207
+ // agent enters the same backoff/give-up path instead of getting stuck in
208
+ // STARTING with no child.
209
+ const msg = err instanceof Error ? err.message : String(err);
210
+ this.scheduleRestartOrStop(agent, `spawn failed: ${msg}`);
211
+ return;
212
+ }
213
+ agent.child = child;
214
+ this.transition(agent, AgentState.RUNNING);
215
+ this.log(`[supervisor] agent ${agent.spec.agentId} running (pid ${child.pid ?? "?"})`);
216
+ // Reset the crash counter only after the agent proves stable, so a fast
217
+ // crash-loop keeps escalating backoff and eventually hits maxRestarts.
218
+ agent.cancelStability?.();
219
+ agent.cancelStability = this.schedule(() => {
220
+ agent.cancelStability = null;
221
+ if (agent.state === AgentState.RUNNING) agent.restarts = 0;
222
+ }, this.stabilityMs);
223
+ // Guard the exit callback: ignore a double-delivery (one-shot) and a stale
224
+ // exit from a child we've already replaced (a late exit must not crash the
225
+ // freshly respawned agent or throw an illegal RESTARTING->CRASHED).
226
+ let exited = false;
227
+ child.onExit((code, signal) => {
228
+ if (exited) return;
229
+ exited = true;
230
+ if (agent.child !== child) return;
231
+ this.onExit(agent, code, signal);
232
+ });
233
+ }
234
+
235
+ private onExit(
236
+ agent: AgentProcess,
237
+ code: number | null,
238
+ signal: NodeJS.Signals | null,
239
+ ): void {
240
+ agent.cancelStability?.();
241
+ agent.cancelStability = null;
242
+ if (agent.stopping || agent.state === AgentState.STOPPED) {
243
+ // Expected exit from stop()/restart(); nothing to do.
244
+ return;
245
+ }
246
+ this.scheduleRestartOrStop(agent, signal ? `signal ${signal}` : `code ${code}`);
247
+ }
248
+
249
+ /**
250
+ * Crash recovery, shared by an unexpected child exit and a synchronous spawn
251
+ * failure: mark CRASHED, then either schedule a backed-off restart or, past the
252
+ * budget, give up (STOPPED).
253
+ */
254
+ private scheduleRestartOrStop(agent: AgentProcess, reason: string): void {
255
+ this.transition(agent, AgentState.CRASHED);
256
+ this.log(`[supervisor] agent ${agent.spec.agentId} crashed (${reason})`);
257
+
258
+ if (agent.restarts >= this.maxRestarts) {
259
+ this.log(
260
+ `[supervisor] agent ${agent.spec.agentId} exceeded ${this.maxRestarts} restarts — giving up`,
261
+ );
262
+ this.transition(agent, AgentState.STOPPED);
263
+ return;
264
+ }
265
+
266
+ const delay = Math.min(
267
+ this.baseBackoffMs * 2 ** agent.restarts,
268
+ this.maxBackoffMs,
269
+ );
270
+ agent.restarts += 1;
271
+ this.transition(agent, AgentState.RESTARTING);
272
+ this.log(
273
+ `[supervisor] agent ${agent.spec.agentId} restart ${agent.restarts}/${this.maxRestarts} in ${delay}ms`,
274
+ );
275
+ agent.cancelBackoff = this.schedule(() => {
276
+ agent.cancelBackoff = null;
277
+ // A stop() during the backoff window wins.
278
+ if (agent.stopping || agent.state === AgentState.STOPPED) return;
279
+ this.spawnAgent(agent);
280
+ }, delay);
281
+ }
282
+
283
+ private transition(agent: AgentProcess, next: AgentState): void {
284
+ const allowed = TRANSITIONS[agent.state];
285
+ if (!allowed.includes(next)) {
286
+ throw new Error(
287
+ `illegal agent transition ${agent.state} -> ${next} (agent ${agent.spec.agentId})`,
288
+ );
289
+ }
290
+ agent.state = next;
291
+ }
292
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Supervisor config parsing (#280 step-12, slice 4).
3
+ *
4
+ * Turns a JSON config (or the parsed object) into validated `AgentSpec`s for the
5
+ * Supervisor. Kept pure + separate from the entry point so it is unit-testable.
6
+ *
7
+ * Config shape:
8
+ * {
9
+ * "agents": [
10
+ * { "id": "reviewer", "tier": "read", "args": ["--await-invite"] },
11
+ * { "tier": "chat", "count": 2 } // count → N agents of this tier
12
+ * ]
13
+ * }
14
+ *
15
+ * Defaults: `args` → ["--await-invite"] (the control plane pushes the conversation,
16
+ * #280 step-03); a missing `id` is generated as `agent-<n>`; `count` (default 1)
17
+ * expands to that many specs with suffixed ids.
18
+ */
19
+
20
+ import type { AgentSpec } from "./supervisor.ts";
21
+
22
+ const VALID_TIERS = ["chat", "read", "write"] as const;
23
+ type Tier = (typeof VALID_TIERS)[number];
24
+
25
+ function isTier(v: unknown): v is Tier {
26
+ return typeof v === "string" && VALID_TIERS.some((t) => t === v);
27
+ }
28
+
29
+ interface RawAgent {
30
+ id?: unknown;
31
+ tier?: unknown;
32
+ args?: unknown;
33
+ count?: unknown;
34
+ env?: unknown;
35
+ }
36
+
37
+ function parseArgsField(raw: unknown): string[] {
38
+ if (raw === undefined) return ["--await-invite"];
39
+ if (!Array.isArray(raw) || raw.some((a) => typeof a !== "string")) {
40
+ throw new Error("agent.args must be an array of strings");
41
+ }
42
+ return raw as string[];
43
+ }
44
+
45
+ function parseEnvField(raw: unknown): Record<string, string> | undefined {
46
+ if (raw === undefined || raw === null) return undefined;
47
+ if (typeof raw !== "object" || Array.isArray(raw)) {
48
+ throw new Error("agent.env must be an object");
49
+ }
50
+ const out: Record<string, string> = {};
51
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
52
+ if (typeof v !== "string") throw new Error(`agent.env.${k} must be a string`);
53
+ out[k] = v;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function parseCount(raw: unknown): number {
59
+ if (raw === undefined) return 1;
60
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1 || raw > 64) {
61
+ throw new Error("agent.count must be an integer between 1 and 64");
62
+ }
63
+ return raw;
64
+ }
65
+
66
+ /**
67
+ * Parse a supervisor config object into a flat list of AgentSpecs.
68
+ * Throws on any malformed entry (fail fast at launch). Agent ids are unique:
69
+ * a duplicate explicit id throws; generated ids never collide.
70
+ */
71
+ export function parseSupervisorConfig(raw: unknown): AgentSpec[] {
72
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
73
+ throw new Error("supervisor config must be an object");
74
+ }
75
+ const agents = (raw as { agents?: unknown }).agents;
76
+ if (!Array.isArray(agents) || agents.length === 0) {
77
+ throw new Error("supervisor config must have a non-empty `agents` array");
78
+ }
79
+
80
+ const specs: AgentSpec[] = [];
81
+ const seen = new Set<string>();
82
+ let generated = 0;
83
+
84
+ for (const entry of agents as RawAgent[]) {
85
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
86
+ throw new Error("each agent must be an object");
87
+ }
88
+ if (!isTier(entry.tier)) {
89
+ throw new Error(`agent.tier must be one of: ${VALID_TIERS.join(", ")}`);
90
+ }
91
+ let explicitId: string | undefined;
92
+ if (entry.id !== undefined) {
93
+ if (typeof entry.id !== "string" || entry.id.trim() === "") {
94
+ throw new Error("agent.id must be a non-empty string");
95
+ }
96
+ explicitId = entry.id;
97
+ }
98
+ const args = parseArgsField(entry.args);
99
+ const env = parseEnvField(entry.env);
100
+ const count = parseCount(entry.count);
101
+
102
+ for (let i = 0; i < count; i++) {
103
+ // count > 1 suffixes the explicit id (rev-1, rev-2…); a single agent keeps
104
+ // the bare id. NOTE: bumping count 1→2 thus renames `rev`→`rev-1`.
105
+ let id: string;
106
+ if (explicitId !== undefined) {
107
+ id = count > 1 ? `${explicitId}-${i + 1}` : explicitId;
108
+ } else {
109
+ generated += 1;
110
+ id = `agent-${generated}`;
111
+ }
112
+ if (seen.has(id)) throw new Error(`duplicate agent id: ${id}`);
113
+ seen.add(id);
114
+ // Independent args array per spec so a later mutation can't bleed across
115
+ // count-expanded siblings.
116
+ specs.push({ agentId: id, tier: entry.tier, args: [...args], ...(env ? { env } : {}) });
117
+ }
118
+ }
119
+
120
+ return specs;
121
+ }
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.5.2",
3
+ "version": "0.6.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
- "viber-channel": "./viber-channel.ts"
7
+ "viber-channel": "./viber-channel.ts",
8
+ "viber-codex-bridge": "./viber-codex-bridge.ts",
9
+ "viber-codex-supervisor": "./viber-codex-supervisor.ts"
8
10
  },
9
11
  "files": [
10
12
  "viber-channel.ts",
13
+ "viber-codex-bridge.ts",
14
+ "viber-codex-supervisor.ts",
11
15
  "lib/",
12
16
  "README.md"
13
17
  ],
@@ -31,6 +35,7 @@
31
35
  "license": "MIT",
32
36
  "scripts": {
33
37
  "start": "bun run viber-channel.ts",
38
+ "start:codex-bridge": "bun run viber-codex-bridge.ts",
34
39
  "test": "bun test"
35
40
  },
36
41
  "dependencies": {