viber-channel 0.5.3 → 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.
@@ -26,10 +26,24 @@ export class ReattachFailedError extends Error {
26
26
  }
27
27
  }
28
28
 
29
+ /**
30
+ * Non-2xx from the conversation mint/join endpoint. `.status` lets the caller
31
+ * react to a 401 (the instance_token was revoked → re-acquire a new instance
32
+ * and retry, #269) distinctly from other failures.
33
+ */
34
+ export class ConversationMintError extends Error {
35
+ status: number;
36
+ constructor(status: number, detail: string) {
37
+ super(`Failed to mint conversation token (HTTP ${status}): ${detail}`);
38
+ this.name = "ConversationMintError";
39
+ this.status = status;
40
+ }
41
+ }
42
+
29
43
  export async function mintConversation(
30
44
  baseUrl: string,
31
45
  projectId: number,
32
- projectToken: string,
46
+ instanceToken: string,
33
47
  fingerprint: string,
34
48
  label: string,
35
49
  /**
@@ -55,7 +69,7 @@ export async function mintConversation(
55
69
  const resp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`, {
56
70
  method: "POST",
57
71
  headers: {
58
- Authorization: `Bearer ${projectToken}`,
72
+ Authorization: `Bearer ${instanceToken}`,
59
73
  "X-Client-Fingerprint": fingerprint,
60
74
  "Content-Type": "application/json",
61
75
  ...cfAccessHeaders(),
@@ -64,7 +78,7 @@ export async function mintConversation(
64
78
  });
65
79
  if (!resp.ok) {
66
80
  const detail = await resp.text();
67
- throw new Error(`Failed to mint conversation token (HTTP ${resp.status}): ${detail}`);
81
+ throw new ConversationMintError(resp.status, detail);
68
82
  }
69
83
  return (await resp.json()) as ConversationMintResponse;
70
84
  }
@@ -81,14 +95,14 @@ export async function mintConversation(
81
95
  export async function reattachConversation(
82
96
  baseUrl: string,
83
97
  projectId: number,
84
- projectToken: string,
98
+ instanceToken: string,
85
99
  fingerprint: string,
86
100
  conversationId: string,
87
101
  ): Promise<ConversationMintResponse> {
88
102
  const resp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`, {
89
103
  method: "POST",
90
104
  headers: {
91
- Authorization: `Bearer ${projectToken}`,
105
+ Authorization: `Bearer ${instanceToken}`,
92
106
  "X-Client-Fingerprint": fingerprint,
93
107
  "Content-Type": "application/json",
94
108
  ...cfAccessHeaders(),
@@ -237,7 +251,7 @@ export const DEFAULT_REATTACH_WINDOW_SECONDS = 3600;
237
251
  * @param sessionPath Path returned by `sessionFilePath(baseUrl, fingerprint, sessionId, dir)`
238
252
  * @param baseUrl Backend base URL
239
253
  * @param projectId Project ID from auth.json
240
- * @param projectToken Bearer token from auth.json
254
+ * @param instanceToken Bearer token from auth.json
241
255
  * @param fingerprint Client fingerprint from auth.json
242
256
  * @param label Conversation label (used only when minting)
243
257
  * @param targetConversationId Optional target for the attach-to-project flow (passed to mint)
@@ -248,7 +262,7 @@ export async function acquireConversation(
248
262
  sessionPath: string,
249
263
  baseUrl: string,
250
264
  projectId: number,
251
- projectToken: string,
265
+ instanceToken: string,
252
266
  fingerprint: string,
253
267
  label: string,
254
268
  targetConversationId?: string | null,
@@ -265,7 +279,7 @@ export async function acquireConversation(
265
279
  if (handleAge < reattachWindowSeconds) {
266
280
  log(`[viber-channel] startup: session handle found (conv_id=${handle.conversation_id}, age=${handleAge}s < ${reattachWindowSeconds}s window), attempting reattach\n`);
267
281
  try {
268
- const result = await reattachConversation(baseUrl, projectId, projectToken, fingerprint, handle.conversation_id);
282
+ const result = await reattachConversation(baseUrl, projectId, instanceToken, fingerprint, handle.conversation_id);
269
283
  log(`[viber-channel] startup: reattach OK, conv_id=${result.conversation_id}\n`);
270
284
  try {
271
285
  writeHandle(sessionPath, result.conversation_id);
@@ -291,7 +305,7 @@ export async function acquireConversation(
291
305
  }
292
306
 
293
307
  // Mint a new conversation
294
- const result = await mintConversation(baseUrl, projectId, projectToken, fingerprint, label, targetConversationId ?? null);
308
+ const result = await mintConversation(baseUrl, projectId, instanceToken, fingerprint, label, targetConversationId ?? null);
295
309
  log(`[viber-channel] startup: mint OK, conv_id=${result.conversation_id}\n`);
296
310
  try {
297
311
  writeHandle(sessionPath, result.conversation_id);
@@ -0,0 +1,137 @@
1
+ /**
2
+ * instance.ts — acquire and use the server-issued per-instance identity (#269).
3
+ *
4
+ * The instance_token is the durable, opaque per-agent credential (D4) the channel
5
+ * presents to JOIN a conversation. It replaces the locally-computed
6
+ * client_fingerprint as the identity source. Acquisition is self-healing: an
7
+ * existing install with no instance_token registers one on first run with its
8
+ * stable project_token (no forced manual reconnect).
9
+ */
10
+ import { cfAccessHeaders } from "./cfAccess.js";
11
+ import { authFilePath, persistInstanceCredentials, type AuthJson } from "./auth.js";
12
+
13
+ export interface RegisterInstanceResponse {
14
+ instance_id: string;
15
+ instance_token: string;
16
+ }
17
+
18
+ /**
19
+ * Non-2xx from POST /api/projects/:id/instances. `.status` lets the caller
20
+ * distinguish a revoked/invalid project_token (401 → reconnect) from a transient
21
+ * server error.
22
+ */
23
+ export class InstanceRegisterError extends Error {
24
+ status: number;
25
+ constructor(status: number, detail: string) {
26
+ super(`Failed to register instance (HTTP ${status}): ${detail}`);
27
+ this.name = "InstanceRegisterError";
28
+ this.status = status;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * POST /api/projects/:id/instances — mint a durable per-instance identity.
34
+ * Authenticated by the stable project_token (the orchestrator credential);
35
+ * the worker also wants X-Client-Fingerprint as a stored signal. Returns the
36
+ * opaque instance_token + its server id.
37
+ */
38
+ export async function registerInstance(
39
+ baseUrl: string,
40
+ projectId: number,
41
+ projectToken: string,
42
+ fingerprint: string,
43
+ label?: string,
44
+ kind?: string,
45
+ ): Promise<RegisterInstanceResponse> {
46
+ const body: Record<string, string> = {};
47
+ if (label !== undefined) body.label = label;
48
+ if (kind !== undefined) body.kind = kind; // runtime: "claude-code" | "codex" | … (#280)
49
+ const resp = await fetch(`${baseUrl}/api/projects/${projectId}/instances`, {
50
+ method: "POST",
51
+ headers: {
52
+ Authorization: `Bearer ${projectToken}`,
53
+ "X-Client-Fingerprint": fingerprint,
54
+ "Content-Type": "application/json",
55
+ ...cfAccessHeaders(),
56
+ },
57
+ body: JSON.stringify(body),
58
+ });
59
+ if (!resp.ok) {
60
+ const detail = await resp.text().catch(() => "");
61
+ throw new InstanceRegisterError(resp.status, detail);
62
+ }
63
+ return (await resp.json()) as RegisterInstanceResponse;
64
+ }
65
+
66
+ export interface AcquiredInstance {
67
+ instance_token: string;
68
+ /**
69
+ * Stable per-instance key used to namespace the conversation handle. The
70
+ * server `instance_id` when known; otherwise the `instance_token` itself
71
+ * (env-injected agents may not carry the id). Both are 1:1 with the instance
72
+ * and durable, so either isolates the handle correctly.
73
+ */
74
+ instance_key: string;
75
+ /**
76
+ * The real server-issued `instance_id`, or undefined when only a bare token is
77
+ * known (env-injected token without VIBER_INSTANCE_ID, or a persisted token with
78
+ * no persisted id). Distinct from `instance_key` — which may be the TOKEN as a
79
+ * namespacing fallback. Use THIS for the self-echo guard + sender attribution:
80
+ * the token must never be compared against server instance ids in
81
+ * `sender_instance_id`; undefined → the self-echo guard fails open (delivers everything).
82
+ */
83
+ instance_id?: string;
84
+ }
85
+
86
+ /**
87
+ * Resolve this channel's instance identity (#269), in priority order:
88
+ * 1. `VIBER_INSTANCE_TOKEN` env (orchestrator-injected, folder-less agents) —
89
+ * used verbatim; `VIBER_INSTANCE_ID` keys the handle if also provided, else
90
+ * the token does.
91
+ * 2. `VIBER_CHANNEL_LABEL` env (#280 step-25 — launcher-named agent session):
92
+ * register a FRESH instance carrying that label, NOT persisted — reusing or
93
+ * overwriting the shared auth.json identity would evict the user's main
94
+ * channel session (the #252 instance-collision gotcha).
95
+ * 3. `auth.json` `instance_token` — reused (durable identity, D4 → same id).
96
+ * 4. register a NEW instance with the stable project_token, persisted back to
97
+ * auth.json (self-healing migration — no forced manual reconnect).
98
+ */
99
+ export async function acquireInstance(
100
+ baseUrl: string,
101
+ auth: AuthJson,
102
+ fingerprint: string,
103
+ log: (msg: string) => void = (m) => process.stderr.write(m),
104
+ cwd: string = process.cwd(),
105
+ ): Promise<AcquiredInstance> {
106
+ const envToken = process.env.VIBER_INSTANCE_TOKEN;
107
+ if (envToken !== undefined && envToken.trim() !== "") {
108
+ const token = envToken.trim();
109
+ const envId = process.env.VIBER_INSTANCE_ID;
110
+ const realId = envId !== undefined && envId.trim() !== "" ? envId.trim() : undefined;
111
+ const key = realId ?? token;
112
+ log(`[viber-channel] instance: using VIBER_INSTANCE_TOKEN (env-injected)\n`);
113
+ return { instance_token: token, instance_key: key, instance_id: realId };
114
+ }
115
+
116
+ const envLabel = process.env.VIBER_CHANNEL_LABEL;
117
+ if (envLabel !== undefined && envLabel.trim() !== "") {
118
+ const label = envLabel.trim();
119
+ log(`[viber-channel] instance: registering fresh labelled instance "${label}"\n`);
120
+ const reg = await registerInstance(baseUrl, auth.project_id, auth.project_token, fingerprint, label);
121
+ return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
122
+ }
123
+
124
+ if (auth.instance_token !== undefined && auth.instance_token.trim() !== "") {
125
+ const realId =
126
+ auth.instance_id !== undefined && auth.instance_id.trim() !== "" ? auth.instance_id : undefined;
127
+ const key = realId ?? auth.instance_token;
128
+ log(`[viber-channel] instance: reusing persisted instance_token (id=${auth.instance_id ?? "?"})\n`);
129
+ return { instance_token: auth.instance_token, instance_key: key, instance_id: realId };
130
+ }
131
+
132
+ 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);
134
+ persistInstanceCredentials(authFilePath(cwd), reg.instance_id, reg.instance_token, log);
135
+ log(`[viber-channel] instance: registered + persisted (id=${reg.instance_id})\n`);
136
+ return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
137
+ }
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,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
+ }