viber-channel 0.5.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/lib/agent_tools.ts +201 -0
- package/lib/auth.ts +50 -1
- package/lib/bridge_core.ts +1019 -0
- package/lib/bridge_spawn.ts +122 -0
- package/lib/bridge_tool_host.ts +191 -0
- package/lib/channel_session.ts +20 -17
- package/lib/connect.ts +10 -1
- package/lib/control_stream.ts +351 -0
- package/lib/conversation.ts +23 -9
- package/lib/dm_stream.ts +287 -0
- package/lib/fingerprint.ts +1 -1
- package/lib/heartbeat.ts +95 -0
- package/lib/instance.ts +164 -0
- package/lib/lockfile.ts +36 -0
- package/lib/messages.ts +32 -0
- package/lib/ollama_chat.ts +106 -0
- package/lib/parent_watchdog.ts +276 -0
- package/lib/peers.ts +113 -0
- package/lib/self_echo.ts +17 -0
- package/lib/supervisor.ts +303 -0
- package/lib/supervisor_config.ts +140 -0
- package/package.json +9 -2
- package/viber-channel.ts +376 -71
- package/viber-codex-bridge.ts +1261 -0
- package/viber-codex-supervisor.ts +96 -0
- package/viber-gemma-bridge.ts +169 -0
|
@@ -0,0 +1,303 @@
|
|
|
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
|
+
* 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;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Minimal child-process surface the supervisor depends on (injectable for tests). */
|
|
68
|
+
export interface ChildHandle {
|
|
69
|
+
readonly pid?: number;
|
|
70
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
71
|
+
/** Fires once when the process exits. */
|
|
72
|
+
onExit(cb: (code: number | null, signal: NodeJS.Signals | null) => void): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type SpawnFn = (spec: AgentSpec) => ChildHandle;
|
|
76
|
+
|
|
77
|
+
/** Schedules `fn` after `ms`; returns a canceller. Injected so tests run instantly. */
|
|
78
|
+
export type RestartScheduler = (fn: () => void, ms: number) => () => void;
|
|
79
|
+
|
|
80
|
+
export interface SupervisorOptions {
|
|
81
|
+
spawn: SpawnFn;
|
|
82
|
+
/**
|
|
83
|
+
* Max consecutive crash-restarts before giving up (then the agent goes STOPPED).
|
|
84
|
+
* This counts RESTARTS, not spawn attempts: maxRestarts=N allows N restarts, i.e.
|
|
85
|
+
* N+1 total spawns, before stopping. Default 5.
|
|
86
|
+
*/
|
|
87
|
+
maxRestarts?: number;
|
|
88
|
+
/** Base backoff (ms) for the first restart; doubles each consecutive crash. Default 1000. */
|
|
89
|
+
baseBackoffMs?: number;
|
|
90
|
+
/** Backoff ceiling (ms). Default 30000. */
|
|
91
|
+
maxBackoffMs?: number;
|
|
92
|
+
/**
|
|
93
|
+
* How long an agent must stay RUNNING before its crash counter resets to 0
|
|
94
|
+
* (default 10000). Without this, a fast crash-loop would reset the counter on
|
|
95
|
+
* every optimistic RUNNING and never escalate backoff or hit maxRestarts.
|
|
96
|
+
*/
|
|
97
|
+
stabilityMs?: number;
|
|
98
|
+
/** Restart scheduler (default setTimeout/clearTimeout). */
|
|
99
|
+
schedule?: RestartScheduler;
|
|
100
|
+
/** Optional log sink (default: stderr). */
|
|
101
|
+
log?: (line: string) => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Per-agent state object held in the registry. */
|
|
105
|
+
class AgentProcess {
|
|
106
|
+
state: AgentState = AgentState.STARTING;
|
|
107
|
+
child: ChildHandle | null = null;
|
|
108
|
+
/** Consecutive crash-restarts; reset to 0 once the agent stays up `stabilityMs`. */
|
|
109
|
+
restarts = 0;
|
|
110
|
+
cancelBackoff: (() => void) | null = null;
|
|
111
|
+
cancelStability: (() => void) | null = null;
|
|
112
|
+
/** Set when stop() was called — suppresses crash-restart. */
|
|
113
|
+
stopping = false;
|
|
114
|
+
|
|
115
|
+
constructor(readonly spec: AgentSpec) {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Snapshot of one agent's status (for the control plane / `list()`). */
|
|
119
|
+
export interface AgentStatus {
|
|
120
|
+
agentId: string;
|
|
121
|
+
tier: string;
|
|
122
|
+
state: AgentState;
|
|
123
|
+
pid?: number;
|
|
124
|
+
restarts: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const defaultSchedule: RestartScheduler = (fn, ms) => {
|
|
128
|
+
const t = setTimeout(fn, ms);
|
|
129
|
+
return () => clearTimeout(t);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export class Supervisor {
|
|
133
|
+
private readonly registry = new Map<string, AgentProcess>();
|
|
134
|
+
private readonly spawn: SpawnFn;
|
|
135
|
+
private readonly maxRestarts: number;
|
|
136
|
+
private readonly baseBackoffMs: number;
|
|
137
|
+
private readonly maxBackoffMs: number;
|
|
138
|
+
private readonly stabilityMs: number;
|
|
139
|
+
private readonly schedule: RestartScheduler;
|
|
140
|
+
private readonly log: (line: string) => void;
|
|
141
|
+
|
|
142
|
+
constructor(opts: SupervisorOptions) {
|
|
143
|
+
this.spawn = opts.spawn;
|
|
144
|
+
this.maxRestarts = opts.maxRestarts ?? 5;
|
|
145
|
+
this.baseBackoffMs = opts.baseBackoffMs ?? 1000;
|
|
146
|
+
this.maxBackoffMs = opts.maxBackoffMs ?? 30_000;
|
|
147
|
+
this.stabilityMs = opts.stabilityMs ?? 10_000;
|
|
148
|
+
this.schedule = opts.schedule ?? defaultSchedule;
|
|
149
|
+
this.log = opts.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Start a new agent. Throws if an agent with this id already exists (not STOPPED). */
|
|
153
|
+
start(spec: AgentSpec): void {
|
|
154
|
+
const existing = this.registry.get(spec.agentId);
|
|
155
|
+
if (existing && existing.state !== AgentState.STOPPED) {
|
|
156
|
+
throw new Error(`agent ${spec.agentId} already supervised (${existing.state})`);
|
|
157
|
+
}
|
|
158
|
+
const agent = new AgentProcess(spec);
|
|
159
|
+
this.registry.set(spec.agentId, agent);
|
|
160
|
+
this.spawnAgent(agent);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Stop an agent: kill the child, mark STOPPED, suppress restart. No-op if unknown. */
|
|
164
|
+
stop(agentId: string): void {
|
|
165
|
+
const agent = this.registry.get(agentId);
|
|
166
|
+
if (!agent || agent.state === AgentState.STOPPED) return;
|
|
167
|
+
agent.stopping = true;
|
|
168
|
+
agent.cancelBackoff?.();
|
|
169
|
+
agent.cancelBackoff = null;
|
|
170
|
+
agent.cancelStability?.();
|
|
171
|
+
agent.cancelStability = null;
|
|
172
|
+
agent.child?.kill("SIGTERM");
|
|
173
|
+
this.transition(agent, AgentState.STOPPED);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Restart an agent now (manual): stop, then start fresh with the same spec.
|
|
178
|
+
* Resets the crash counter to 0 (a deliberate restart is a clean slate). Works
|
|
179
|
+
* even on a STOPPED agent (relaunches it); no-op only for an unknown id.
|
|
180
|
+
*/
|
|
181
|
+
restart(agentId: string): void {
|
|
182
|
+
const agent = this.registry.get(agentId);
|
|
183
|
+
if (!agent) return;
|
|
184
|
+
const spec = agent.spec;
|
|
185
|
+
this.stop(agentId);
|
|
186
|
+
this.registry.delete(agentId);
|
|
187
|
+
this.start(spec);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Status snapshot of every supervised agent. */
|
|
191
|
+
list(): AgentStatus[] {
|
|
192
|
+
return [...this.registry.values()].map((a) => ({
|
|
193
|
+
agentId: a.spec.agentId,
|
|
194
|
+
tier: a.spec.tier,
|
|
195
|
+
state: a.state,
|
|
196
|
+
pid: a.child?.pid,
|
|
197
|
+
restarts: a.restarts,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Stop every agent (e.g. supervisor shutdown). */
|
|
202
|
+
stopAll(): void {
|
|
203
|
+
for (const id of this.registry.keys()) this.stop(id);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
private spawnAgent(agent: AgentProcess): void {
|
|
209
|
+
if (agent.state === AgentState.RESTARTING) {
|
|
210
|
+
this.transition(agent, AgentState.STARTING);
|
|
211
|
+
}
|
|
212
|
+
agent.stopping = false;
|
|
213
|
+
let child: ChildHandle;
|
|
214
|
+
try {
|
|
215
|
+
child = this.spawn(agent.spec);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
// A synchronous launch failure is treated like an immediate crash, so the
|
|
218
|
+
// agent enters the same backoff/give-up path instead of getting stuck in
|
|
219
|
+
// STARTING with no child.
|
|
220
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
221
|
+
this.scheduleRestartOrStop(agent, `spawn failed: ${msg}`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
agent.child = child;
|
|
225
|
+
this.transition(agent, AgentState.RUNNING);
|
|
226
|
+
this.log(`[supervisor] agent ${agent.spec.agentId} running (pid ${child.pid ?? "?"})`);
|
|
227
|
+
// Reset the crash counter only after the agent proves stable, so a fast
|
|
228
|
+
// crash-loop keeps escalating backoff and eventually hits maxRestarts.
|
|
229
|
+
agent.cancelStability?.();
|
|
230
|
+
agent.cancelStability = this.schedule(() => {
|
|
231
|
+
agent.cancelStability = null;
|
|
232
|
+
if (agent.state === AgentState.RUNNING) agent.restarts = 0;
|
|
233
|
+
}, this.stabilityMs);
|
|
234
|
+
// Guard the exit callback: ignore a double-delivery (one-shot) and a stale
|
|
235
|
+
// exit from a child we've already replaced (a late exit must not crash the
|
|
236
|
+
// freshly respawned agent or throw an illegal RESTARTING->CRASHED).
|
|
237
|
+
let exited = false;
|
|
238
|
+
child.onExit((code, signal) => {
|
|
239
|
+
if (exited) return;
|
|
240
|
+
exited = true;
|
|
241
|
+
if (agent.child !== child) return;
|
|
242
|
+
this.onExit(agent, code, signal);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private onExit(
|
|
247
|
+
agent: AgentProcess,
|
|
248
|
+
code: number | null,
|
|
249
|
+
signal: NodeJS.Signals | null,
|
|
250
|
+
): void {
|
|
251
|
+
agent.cancelStability?.();
|
|
252
|
+
agent.cancelStability = null;
|
|
253
|
+
if (agent.stopping || agent.state === AgentState.STOPPED) {
|
|
254
|
+
// Expected exit from stop()/restart(); nothing to do.
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.scheduleRestartOrStop(agent, signal ? `signal ${signal}` : `code ${code}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Crash recovery, shared by an unexpected child exit and a synchronous spawn
|
|
262
|
+
* failure: mark CRASHED, then either schedule a backed-off restart or, past the
|
|
263
|
+
* budget, give up (STOPPED).
|
|
264
|
+
*/
|
|
265
|
+
private scheduleRestartOrStop(agent: AgentProcess, reason: string): void {
|
|
266
|
+
this.transition(agent, AgentState.CRASHED);
|
|
267
|
+
this.log(`[supervisor] agent ${agent.spec.agentId} crashed (${reason})`);
|
|
268
|
+
|
|
269
|
+
if (agent.restarts >= this.maxRestarts) {
|
|
270
|
+
this.log(
|
|
271
|
+
`[supervisor] agent ${agent.spec.agentId} exceeded ${this.maxRestarts} restarts — giving up`,
|
|
272
|
+
);
|
|
273
|
+
this.transition(agent, AgentState.STOPPED);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const delay = Math.min(
|
|
278
|
+
this.baseBackoffMs * 2 ** agent.restarts,
|
|
279
|
+
this.maxBackoffMs,
|
|
280
|
+
);
|
|
281
|
+
agent.restarts += 1;
|
|
282
|
+
this.transition(agent, AgentState.RESTARTING);
|
|
283
|
+
this.log(
|
|
284
|
+
`[supervisor] agent ${agent.spec.agentId} restart ${agent.restarts}/${this.maxRestarts} in ${delay}ms`,
|
|
285
|
+
);
|
|
286
|
+
agent.cancelBackoff = this.schedule(() => {
|
|
287
|
+
agent.cancelBackoff = null;
|
|
288
|
+
// A stop() during the backoff window wins.
|
|
289
|
+
if (agent.stopping || agent.state === AgentState.STOPPED) return;
|
|
290
|
+
this.spawnAgent(agent);
|
|
291
|
+
}, delay);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private transition(agent: AgentProcess, next: AgentState): void {
|
|
295
|
+
const allowed = TRANSITIONS[agent.state];
|
|
296
|
+
if (!allowed.includes(next)) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`illegal agent transition ${agent.state} -> ${next} (agent ${agent.spec.agentId})`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
agent.state = next;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
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;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseArgsField(raw: unknown): string[] {
|
|
45
|
+
if (raw === undefined) return ["--await-invite"];
|
|
46
|
+
if (!Array.isArray(raw) || raw.some((a) => typeof a !== "string")) {
|
|
47
|
+
throw new Error("agent.args must be an array of strings");
|
|
48
|
+
}
|
|
49
|
+
return raw as string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseEnvField(raw: unknown): Record<string, string> | undefined {
|
|
53
|
+
if (raw === undefined || raw === null) return undefined;
|
|
54
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
55
|
+
throw new Error("agent.env must be an object");
|
|
56
|
+
}
|
|
57
|
+
const out: Record<string, string> = {};
|
|
58
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
59
|
+
if (typeof v !== "string") throw new Error(`agent.env.${k} must be a string`);
|
|
60
|
+
out[k] = v;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseCount(raw: unknown): number {
|
|
66
|
+
if (raw === undefined) return 1;
|
|
67
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1 || raw > 64) {
|
|
68
|
+
throw new Error("agent.count must be an integer between 1 and 64");
|
|
69
|
+
}
|
|
70
|
+
return raw;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse a supervisor config object into a flat list of AgentSpecs.
|
|
75
|
+
* Throws on any malformed entry (fail fast at launch). Agent ids are unique:
|
|
76
|
+
* a duplicate explicit id throws; generated ids never collide.
|
|
77
|
+
*/
|
|
78
|
+
export function parseSupervisorConfig(raw: unknown): AgentSpec[] {
|
|
79
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
80
|
+
throw new Error("supervisor config must be an object");
|
|
81
|
+
}
|
|
82
|
+
const agents = (raw as { agents?: unknown }).agents;
|
|
83
|
+
if (!Array.isArray(agents) || agents.length === 0) {
|
|
84
|
+
throw new Error("supervisor config must have a non-empty `agents` array");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const specs: AgentSpec[] = [];
|
|
88
|
+
const seen = new Set<string>();
|
|
89
|
+
let generated = 0;
|
|
90
|
+
|
|
91
|
+
for (const entry of agents as RawAgent[]) {
|
|
92
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
93
|
+
throw new Error("each agent must be an object");
|
|
94
|
+
}
|
|
95
|
+
if (!isTier(entry.tier)) {
|
|
96
|
+
throw new Error(`agent.tier must be one of: ${VALID_TIERS.join(", ")}`);
|
|
97
|
+
}
|
|
98
|
+
let explicitId: string | undefined;
|
|
99
|
+
if (entry.id !== undefined) {
|
|
100
|
+
if (typeof entry.id !== "string" || entry.id.trim() === "") {
|
|
101
|
+
throw new Error("agent.id must be a non-empty string");
|
|
102
|
+
}
|
|
103
|
+
explicitId = entry.id;
|
|
104
|
+
}
|
|
105
|
+
const args = parseArgsField(entry.args);
|
|
106
|
+
const env = parseEnvField(entry.env);
|
|
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;
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i < count; i++) {
|
|
115
|
+
// count > 1 suffixes the explicit id (rev-1, rev-2…); a single agent keeps
|
|
116
|
+
// the bare id. NOTE: bumping count 1→2 thus renames `rev`→`rev-1`.
|
|
117
|
+
let id: string;
|
|
118
|
+
if (explicitId !== undefined) {
|
|
119
|
+
id = count > 1 ? `${explicitId}-${i + 1}` : explicitId;
|
|
120
|
+
} else {
|
|
121
|
+
generated += 1;
|
|
122
|
+
id = `agent-${generated}`;
|
|
123
|
+
}
|
|
124
|
+
if (seen.has(id)) throw new Error(`duplicate agent id: ${id}`);
|
|
125
|
+
seen.add(id);
|
|
126
|
+
// Independent args array per spec so a later mutation can't bleed across
|
|
127
|
+
// count-expanded siblings.
|
|
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
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return specs;
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
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
|
-
"viber-channel": "./viber-channel.ts"
|
|
7
|
+
"viber-channel": "./viber-channel.ts",
|
|
8
|
+
"viber-codex-bridge": "./viber-codex-bridge.ts",
|
|
9
|
+
"viber-gemma-bridge": "./viber-gemma-bridge.ts",
|
|
10
|
+
"viber-codex-supervisor": "./viber-codex-supervisor.ts"
|
|
8
11
|
},
|
|
9
12
|
"files": [
|
|
10
13
|
"viber-channel.ts",
|
|
14
|
+
"viber-codex-bridge.ts",
|
|
15
|
+
"viber-gemma-bridge.ts",
|
|
16
|
+
"viber-codex-supervisor.ts",
|
|
11
17
|
"lib/",
|
|
12
18
|
"README.md"
|
|
13
19
|
],
|
|
@@ -31,6 +37,7 @@
|
|
|
31
37
|
"license": "MIT",
|
|
32
38
|
"scripts": {
|
|
33
39
|
"start": "bun run viber-channel.ts",
|
|
40
|
+
"start:codex-bridge": "bun run viber-codex-bridge.ts",
|
|
34
41
|
"test": "bun test"
|
|
35
42
|
},
|
|
36
43
|
"dependencies": {
|