viber-channel 0.8.11 → 0.8.13

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.
@@ -18,6 +18,7 @@ import { randomBytes } from "node:crypto";
18
18
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { cfAccessHeaders } from "./cfAccess.js";
21
+ import { messageForReason, parseSpawnResult, rosterNames, type SpawnReason } from "./spawn_reason.js";
21
22
 
22
23
  /** A validated role from the server snapshot (allowlisted shape). */
23
24
  export interface SnapshotRole {
@@ -148,7 +149,13 @@ export function validateAgainstPolicy(cmd: ClaimedCommand, policy: RunnerPolicy)
148
149
 
149
150
  export type TeamSpawnRunner = (
150
151
  args: string[],
151
- ) => Promise<{ ok: boolean; recap: string }>;
152
+ /** #496: the nonce the child must echo on its outcome marker for it to be
153
+ * believed. REQUIRED (Codex review): an optional nonce lets a future launcher
154
+ * omit it and silently fall back to trusting any marker — the very hole the
155
+ * explicit trust parameter closes. A mock may ignore the argument; it cannot
156
+ * forget that it exists. */
157
+ nonce: string,
158
+ ) => Promise<{ ok: boolean; recap: string; reason?: SpawnReason; detail?: string }>;
152
159
 
153
160
  /**
154
161
  * Build a CURATED child environment (Codex review, blocking): `execFile` would
@@ -202,7 +209,7 @@ export function vibeMasterBaseCmd(source: NodeJS.ProcessEnv = process.env): stri
202
209
  /** Default launcher: shell vibe-master via execFile with an ARGV ARRAY (never a
203
210
  * shell string), a CURATED env, bounded time + output, and process-tree kill on
204
211
  * timeout. The binary is resolved via VIBE_MASTER_CMD (default `vibe-master`). */
205
- export const execFileTeamSpawn: TeamSpawnRunner = (args) => {
212
+ export const execFileTeamSpawn: TeamSpawnRunner = (args, nonce) => {
206
213
  const base = vibeMasterBaseCmd();
207
214
  const bin = base[0] as string;
208
215
  return new Promise((resolve) => {
@@ -211,8 +218,19 @@ export const execFileTeamSpawn: TeamSpawnRunner = (args) => {
211
218
  [...base.slice(1), ...args],
212
219
  { timeout: 5 * 60 * 1000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, env: buildChildEnv() },
213
220
  (err, stdout, stderr) => {
221
+ // #496: read the outcome marker from RAW stderr, BEFORE redaction and
222
+ // before the 4 000-char cut. The marker is written last, so it is the
223
+ // first thing `.slice()` drops on a real (verbose) roster — parsing the
224
+ // recap would pass every test and fail every actual spawn. Redaction can
225
+ // also mangle the JSON. Both hazards disappear by reading first.
226
+ const parsed = parseSpawnResult(stderr ?? "", { nonce });
214
227
  const recap = redactSecrets(`${stdout}\n${stderr}`.trim()).slice(0, 4000);
215
- resolve({ ok: !err, recap });
228
+ resolve({
229
+ ok: !err,
230
+ recap,
231
+ ...(parsed?.reason ? { reason: parsed.reason } : {}),
232
+ ...(parsed?.detail ? { detail: parsed.detail } : {}),
233
+ });
216
234
  },
217
235
  );
218
236
  });
@@ -273,6 +291,49 @@ export interface ExecDeps {
273
291
  writeSpec?: SpecFileWriter;
274
292
  /** Injectable local-log writer (tests). Returns the log path. */
275
293
  writeLog?: (commandId: string, recap: string) => string;
294
+ /** Injectable daemon-stderr writer (tests). Defaults to process.stderr. */
295
+ writeDaemonLine?: (line: string) => void;
296
+ }
297
+
298
+ /** Where the log lives, as shown to whoever reads the WEB (#506): RELATIVE, so
299
+ * it names the file without carrying the machine's absolute path — home
300
+ * directory and user name included — to the server. The absolute path stays on
301
+ * the daemon's own stderr, which never leaves the machine. */
302
+ export function runnerLogRef(commandId: string): string {
303
+ return `.viber/runner-logs/${commandId}.log`;
304
+ }
305
+
306
+ /** What `writeRunnerLog` returns when it could NOT write (unwritable dir, full
307
+ * disk). Named, because the remote message must not point at a file that was
308
+ * never created. */
309
+ export const LOG_UNAVAILABLE = "(local log unavailable)";
310
+
311
+ /** The log reference to put in the REMOTE message, or undefined when no log
312
+ * exists (#506 review, both reviewers): pointing someone at
313
+ * `.viber/runner-logs/<id>.log` when the write failed would be this issue in
314
+ * miniature — a component asserting more than it knows, on the very sentence
315
+ * this step fixes. Callers fall back to the no-path wording. */
316
+ export function remoteLogRef(commandId: string, logPath: string): string | undefined {
317
+ return logPath === LOG_UNAVAILABLE ? undefined : runnerLogRef(commandId);
318
+ }
319
+
320
+ /** The one line of a recap worth putting on the daemon's stderr: its first
321
+ * `error:` line, bounded. Already redacted upstream by `redactSecrets`. Falls
322
+ * back to the first non-empty line when the CLI printed no `error:` line, so a
323
+ * failure is never reported as silence.
324
+ *
325
+ * The result must be ONE line: it is written with a trailing newline onto a
326
+ * shared stream, so any control character surviving inside it could forge a
327
+ * second log line. Hence splitting on a LONE `\r` too (Codex review — a bare CR
328
+ * is what a progress-bar writer emits), and stripping the remaining C0/C1
329
+ * controls rather than trusting the split alone. */
330
+ export function firstErrorLine(recap: string, max = 300): string {
331
+ const lines = recap
332
+ .split(/\r\n|\r|\n/)
333
+ .map((l) => l.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim())
334
+ .filter(Boolean);
335
+ const line = lines.find((l) => l.startsWith("error:")) ?? lines[0] ?? "(no output)";
336
+ return line.slice(0, max);
276
337
  }
277
338
 
278
339
  /** Write the full launch recap to a 0600 LOCAL log (never sent to the server —
@@ -285,11 +346,23 @@ export function writeRunnerLog(commandId: string, recap: string, cwd: string = p
285
346
  mkdirSync(dir, { recursive: true });
286
347
  writeFileSync(path, recap, { mode: 0o600 });
287
348
  } catch {
288
- return "(local log unavailable)";
349
+ return LOG_UNAVAILABLE;
289
350
  }
290
351
  return path;
291
352
  }
292
353
 
354
+ /** A `failed` report body carrying a structured reason (#496). The sentence is
355
+ * derived from the REASON, so what the web shows is server/runner-authored copy,
356
+ * never a raw client string; `error` keeps the operator-facing precision the
357
+ * local log used to hold alone. */
358
+ function refusalBody(
359
+ cmd: ClaimedCommand,
360
+ reason: SpawnReason,
361
+ error: string,
362
+ ): Record<string, unknown> {
363
+ return { fencing_token: cmd.fencing_token, status: "failed", reason, error };
364
+ }
365
+
293
366
  async function report(
294
367
  auth: RunnerAuthLite,
295
368
  cmdId: string,
@@ -331,14 +404,24 @@ export async function executeClaim(
331
404
  try {
332
405
  policy = deps.policy ?? loadRunnerPolicy();
333
406
  } catch (err) {
334
- const reason = err instanceof RunnerPolicyInvalidError ? err.message : "invalid local policy";
335
- await report(auth, cmd.id, { fencing_token: cmd.fencing_token, status: "failed", error: `local policy invalid: ${reason}` }, fetchImpl);
407
+ const cause = err instanceof RunnerPolicyInvalidError ? err.message : "invalid local policy";
408
+ await report(
409
+ auth,
410
+ cmd.id,
411
+ refusalBody(cmd, "policy_invalid", `local policy invalid: ${cause}`),
412
+ fetchImpl,
413
+ );
336
414
  return "failed";
337
415
  }
338
416
 
339
417
  const refusal = validateAgainstPolicy(cmd, policy);
340
418
  if (refusal) {
341
- await report(auth, cmd.id, { fencing_token: cmd.fencing_token, status: "failed", error: `local policy: ${refusal}` }, fetchImpl);
419
+ await report(
420
+ auth,
421
+ cmd.id,
422
+ refusalBody(cmd, "policy_refused", `local policy: ${refusal}`),
423
+ fetchImpl,
424
+ );
342
425
  return "failed";
343
426
  }
344
427
 
@@ -350,7 +433,7 @@ export async function executeClaim(
350
433
  const s = await report(
351
434
  auth,
352
435
  cmd.id,
353
- { fencing_token: cmd.fencing_token, status: "failed", error: "prior partial run detected on this runner — not relaunched" },
436
+ refusalBody(cmd, "partial_run", "prior partial run detected on this runner — not relaunched"),
354
437
  fetchImpl,
355
438
  );
356
439
  if (s >= 200 && s < 300) inflight.clear(cmd.id);
@@ -360,7 +443,12 @@ export async function executeClaim(
360
443
  // template_name is the positional <name> in argv: positive allowlist (no
361
444
  // leading '-', no control char) — argv-flag-injection defense.
362
445
  if (!/^[A-Za-z0-9_.][A-Za-z0-9 _.-]{0,63}$/.test(cmd.template_name)) {
363
- await report(auth, cmd.id, { fencing_token: cmd.fencing_token, status: "failed", error: "unsafe template_name" }, fetchImpl);
446
+ await report(
447
+ auth,
448
+ cmd.id,
449
+ refusalBody(cmd, "unsafe_template_name", "unsafe template_name"),
450
+ fetchImpl,
451
+ );
364
452
  return "failed";
365
453
  }
366
454
 
@@ -378,21 +466,65 @@ export async function executeClaim(
378
466
  const { path: specPath, cleanup } = (deps.writeSpec ?? fsWriteSpec())(spec);
379
467
  try {
380
468
  // Allowlisted argv (C3): fixed flags + validated values, no free string.
381
- const args = ["team", "spawn", "--spec-file", specPath, "--prefix", cmd.prefix];
469
+ // #496: a fresh nonce per launch, echoed back on the outcome marker.
470
+ // Threat model, stated precisely (Codex review): this DE-CORRELATES an
471
+ // accidental look-alike — an agent that prints this contract in its own
472
+ // output on the shared, deliberately-inherited stderr — which is the case
473
+ // that actually happens here. It is NOT authentication: a same-user process
474
+ // can read the parent's command line on Windows and forge the value. Nothing
475
+ // on this path can defend against a hostile local process, which already runs
476
+ // as the user; what protects the RENDERED text is the roster intersection
477
+ // below, not this nonce.
478
+ const nonce = randomBytes(16).toString("hex");
479
+ const args = ["team", "spawn", "--spec-file", specPath, "--prefix", cmd.prefix, "--result-nonce", nonce];
382
480
  if (cmd.team_name) args.push("--team", cmd.team_name);
383
481
  if (cmd.env) args.push("--env", cmd.env);
384
482
 
385
483
  inflight.mark(cmd.id); // durable "started here" BEFORE launch (C4)
386
- const { ok, recap } = await runTeamSpawn(args);
387
-
388
- // Generic remote report; full recap to a 0600 LOCAL log only (no secret leak).
484
+ const { ok, recap, reason, detail } = await runTeamSpawn(args, nonce);
485
+
486
+ // #496: the failure now travels as a REASON plus a sentence built from it —
487
+ // whoever clicked Spawn in a browser can act on what they read. The full
488
+ // recap still goes ONLY to the 0600 local log (no secret leak); what changed
489
+ // is that the local log is no longer the sole place the cause exists. With no
490
+ // marker (older vibe-master, output lost to maxBuffer) we degrade to exactly
491
+ // the previous message rather than inventing a cause.
389
492
  const logPath = deps.writeLog ? deps.writeLog(cmd.id, recap) : writeRunnerLog(cmd.id, recap);
493
+ // #506, the issue's second defect: the remote message sent people to "the
494
+ // machine's log", and the log they actually open — the daemon's — only ever
495
+ // said "reconcile: handled 1 command(s)". A failure now states itself where
496
+ // it is read FIRST. This stream is local, so the ABSOLUTE path belongs here;
497
+ // what travels to the server is the relative ref below.
498
+ if (!ok) {
499
+ const writeDaemonLine =
500
+ deps.writeDaemonLine ?? ((line: string) => process.stderr.write(`${line}\n`));
501
+ writeDaemonLine(
502
+ `[runner] spawn FAILED command=${cmd.id} reason=${reason ?? "unknown"} log=${logPath} ${firstErrorLine(recap)}`,
503
+ );
504
+ }
505
+ // Resolved ONCE: three call sites in a ternary could drift apart under a
506
+ // later edit, and they must agree on whether a log exists (Opus review).
507
+ const logRef = remoteLogRef(cmd.id, logPath);
390
508
  const finalStatus = await report(
391
509
  auth,
392
510
  cmd.id,
393
511
  ok
394
512
  ? { fencing_token: cmd.fencing_token, status: "succeeded", result: { message: "team spawn completed", log: logPath } }
395
- : { fencing_token: cmd.fencing_token, status: "failed", error: "team spawn failed — see local runner log" },
513
+ : {
514
+ fencing_token: cmd.fencing_token,
515
+ status: "failed",
516
+ error: reason
517
+ ? messageForReason(
518
+ reason,
519
+ detail,
520
+ rosterNames(cmd.prefix, cmd.template_spec.roles),
521
+ logRef,
522
+ )
523
+ : logRef
524
+ ? `team spawn failed — see ${logRef} on that machine`
525
+ : "team spawn failed, and the local log could not be written on that machine",
526
+ ...(reason ? { reason } : {}),
527
+ },
396
528
  fetchImpl,
397
529
  );
398
530
  // Clear the inflight marker ONLY when the TERMINAL report is accepted (2xx)
@@ -0,0 +1,230 @@
1
+ /**
2
+ * spawn_reason.ts — the runner's half of the spawn-outcome contract (#496).
3
+ *
4
+ * A spawn refused on this machine used to reach the web as one sentence,
5
+ * "team spawn failed — see local runner log". Whoever clicked Spawn from a
6
+ * browser has no terminal and no access to that log: they saw a red failure and
7
+ * had nowhere to go. The cause existed — it was on the runner's stderr — and was
8
+ * thrown away here.
9
+ *
10
+ * `vibe-master` now emits a machine line (`VIBEMASTER_RESULT {"reason":…}`,
11
+ * see vibe-master/lib/spawn_result.ts). This module extracts it and turns a
12
+ * reason into a sentence the web can show. The runner's OWN refusals (policy,
13
+ * unsafe input, a prior partial run) carry reasons from the same vocabulary, so
14
+ * the web never has to speak two languages.
15
+ */
16
+
17
+ /** Reasons a spawn can be refused. Mirrors vibe-master's vocabulary plus the
18
+ * four the runner produces itself. The API keeps its own allowlist: an unknown
19
+ * reason is dropped there, never stored raw — it comes from a client machine. */
20
+ export const SPAWN_REASONS = [
21
+ // Emitted by vibe-master
22
+ "already_running",
23
+ "partial_team",
24
+ "template_invalid",
25
+ "roster_invalid",
26
+ "codex_unavailable",
27
+ "launch_failed",
28
+ // Emitted by the runner itself
29
+ "policy_invalid",
30
+ "policy_refused",
31
+ "unsafe_template_name",
32
+ "partial_run",
33
+ ] as const;
34
+
35
+ export type SpawnReason = (typeof SPAWN_REASONS)[number];
36
+
37
+ const MARKER = "VIBEMASTER_RESULT";
38
+
39
+ /** Detail is diagnostic context, never shown raw to a user (the server bounds it
40
+ * again and the web renders from the REASON). Bounded here too so a runaway
41
+ * child cannot push a megabyte into a report. */
42
+ const DETAIL_MAX = 300;
43
+
44
+ export interface ParsedSpawnResult {
45
+ reason: SpawnReason;
46
+ detail?: string;
47
+ }
48
+
49
+ /**
50
+ * Keep only the names that belong to THIS command's roster.
51
+ *
52
+ * A charset check is not enough (Codex review): `session-expired, sign-in, now`
53
+ * satisfies any agent-id pattern and would be rendered as though it listed
54
+ * members. The only trustworthy authority on what a name may be is the roster the
55
+ * SERVER validated and sent — so `detail` is not sanitized, it is INTERSECTED
56
+ * with that roster. A name the server never asked to spawn cannot reach the
57
+ * screen, whatever the runner sent, and prose cannot survive at all.
58
+ */
59
+ const MAX_NAMES = 10;
60
+
61
+ export function keepRosterNames(
62
+ detail: string | undefined,
63
+ roster: readonly string[],
64
+ ): string | undefined {
65
+ if (!detail) return undefined;
66
+ const allowed = new Set(roster);
67
+ const names = detail
68
+ .split(",")
69
+ .map((t) => t.trim())
70
+ .filter((t) => allowed.has(t))
71
+ .slice(0, MAX_NAMES);
72
+ return names.length > 0 ? names.join(", ") : undefined;
73
+ }
74
+
75
+ /**
76
+ * The agent names a command may talk about: `{prefix}-{role}`, plus
77
+ * `{prefix}-{role}-{n}` for replicated roles. Mirrors vibe-master's naming in
78
+ * `resolveTeamSpawn` — the two must move together.
79
+ */
80
+ export function rosterNames(
81
+ prefix: string,
82
+ roles: readonly { role: string; count?: number }[],
83
+ ): string[] {
84
+ const names: string[] = [];
85
+ for (const r of roles) {
86
+ const count = r.count && r.count > 1 ? r.count : 1;
87
+ if (count === 1) {
88
+ names.push(`${prefix}-${r.role}`);
89
+ continue;
90
+ }
91
+ for (let i = 1; i <= count; i++) names.push(`${prefix}-${r.role}-${i}`);
92
+ }
93
+ return names;
94
+ }
95
+
96
+ /**
97
+ * Extract the outcome marker from RAW child stderr.
98
+ *
99
+ * Read this before the recap is built, never after: the recap is redacted and
100
+ * then cut to 4 000 characters, and the marker is written LAST — exactly the end
101
+ * that the cut removes. A four-member roster overflows that budget easily, so
102
+ * parsing the recap would work in tests and fail on a real spawn.
103
+ *
104
+ * Scans for the LAST line that starts with the marker AND parses AND carries a
105
+ * known reason — not simply the last line. On `launch_failed` the marker is
106
+ * written after child processes have inherited this same stderr, so trailing
107
+ * child output can follow it; and a line quoting the marker inside other text is
108
+ * ignored because the prefix must start the line.
109
+ *
110
+ * Returns null when there is no marker (an older vibe-master, or output lost to
111
+ * a maxBuffer overflow) — the caller then behaves exactly as before.
112
+ */
113
+ export type MarkerTrust =
114
+ /** Believe only a marker echoing this nonce (the runner path — the default). */
115
+ | { nonce: string }
116
+ /** No emitter check at all. Named so a caller cannot fall into it by omission
117
+ * (Opus review): an optional nonce silently skipped would reopen "last valid
118
+ * marker wins" with no test turning red. Only for reading output nobody
119
+ * else could have written into — a human running the CLI, or a unit test. */
120
+ | { trustUnauthenticated: true };
121
+
122
+ export function parseSpawnResult(
123
+ rawStderr: string,
124
+ trust: MarkerTrust,
125
+ ): ParsedSpawnResult | null {
126
+ const expectedNonce = "nonce" in trust ? trust.nonce : undefined;
127
+ if (expectedNonce !== undefined && expectedNonce.length === 0) {
128
+ throw new Error("parseSpawnResult: empty nonce — pass {trustUnauthenticated:true} to skip the check deliberately");
129
+ }
130
+ const known = new Set<string>(SPAWN_REASONS);
131
+ for (const line of rawStderr.split(/\r?\n/).reverse()) {
132
+ if (!line.startsWith(`${MARKER} `)) continue;
133
+ let parsed: unknown;
134
+ try {
135
+ parsed = JSON.parse(line.slice(MARKER.length + 1));
136
+ } catch {
137
+ continue; // truncated or interleaved — keep looking further back
138
+ }
139
+ if (typeof parsed !== "object" || parsed === null) continue;
140
+ const { reason, detail, nonce } = parsed as {
141
+ reason?: unknown;
142
+ detail?: unknown;
143
+ nonce?: unknown;
144
+ };
145
+ // Emitter proof. Spawned agents inherit this stderr, and an agent working on
146
+ // THIS repo prints this very contract in its own output — "the last valid
147
+ // marker wins" would let such a line overrule the real outcome and turn a
148
+ // launch_failed into a neutral already_running. Only the process we handed
149
+ // the nonce to can echo it.
150
+ if (expectedNonce && nonce !== expectedNonce) continue;
151
+ if (typeof reason !== "string" || !known.has(reason)) continue;
152
+ return {
153
+ reason: reason as SpawnReason,
154
+ ...(typeof detail === "string" && detail.length > 0
155
+ ? { detail: detail.slice(0, DETAIL_MAX) }
156
+ : {}),
157
+ };
158
+ }
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * The sentence the web shows. Built from the REASON. Where agent names carry the
164
+ * actionable part ("which member is missing"), they go through
165
+ * {@link keepRosterNames} first, so what is interpolated is provably a subset of
166
+ * the roster the server itself validated — never a sentence a machine chose.
167
+ * The roster is REQUIRED (Opus review): defaulting it to `[]` would let a caller
168
+ * drop every name in silence — the same "bypass by omission" the explicit marker
169
+ * trust closes, failing safe but failing quietly. A caller with no roster passes
170
+ * `[]` deliberately, and that shows in the diff.
171
+ *
172
+ * `policy_refused` interpolates NOTHING: its detail is free-form policy text, not
173
+ * a name list, so it cannot be shape-checked. The precision stays in the
174
+ * operator-facing `error`, off the screen.
175
+ */
176
+ export function messageForReason(
177
+ reason: SpawnReason,
178
+ detail?: string,
179
+ roster: readonly string[],
180
+ logRef?: string,
181
+ ): string {
182
+ const safe = keepRosterNames(detail, roster);
183
+ const names = safe ? ` (${safe})` : "";
184
+ switch (reason) {
185
+ case "already_running":
186
+ // ATTRIBUTED, not asserted (Opus review). Of the nine reasons this is the
187
+ // ONLY one that renders as reassuring — every other paints red. So it is
188
+ // the one a buggy or compromised runner could use to claim "nothing was
189
+ // launched" while agents are actually running: the exact false green this
190
+ // issue exists to remove, re-entering through the pipe we just built. The
191
+ // server cannot verify the claim, so the copy SOURCES it instead. A user
192
+ // whose screen disagrees with their machine then knows what to doubt.
193
+ return `the runner reports that a team with this prefix was already running on this machine${names} — so nothing was launched`;
194
+ case "partial_team":
195
+ return `the runner reports that a team with this prefix is only PARTLY running — these members are missing${names}. Stop the survivors or pick a fresh prefix`;
196
+ case "template_invalid":
197
+ // On the web path this is NOT a user mistake: the runner never passes a
198
+ // template NAME, it writes the server-validated snapshot to a file and
199
+ // passes --spec-file. So this means the server's own snapshot could not be
200
+ // read back — a defect. Telling the user to go read a log they cannot open,
201
+ // for a problem they did not cause, would repeat this issue in miniature.
202
+ // #506, second defect of the issue: this sentence sent the reader to "the
203
+ // machine's log" WITHOUT naming it — and the log they did open, the
204
+ // daemon's, said nothing about the failure. `logRef` is the RELATIVE path
205
+ // (`.viber/runner-logs/<command_id>.log`): it locates the file without
206
+ // shipping an absolute Windows path — hence the machine's user name — to
207
+ // the server and onto someone's screen.
208
+ return `the roster sent for this spawn could not be read back on the runner machine — this is likely a defect, not something you did wrong; the full error is in ${logRef ?? "the machine's log"} on that machine`;
209
+ case "roster_invalid":
210
+ // #506: the OTHER half of what template_invalid used to swallow. The
211
+ // snapshot was read fine — it is the ROSTER that cannot be launched as
212
+ // written, and that IS the template author's to fix. Two catches, two
213
+ // reasons: this one says "you can fix this", template_invalid says "this
214
+ // is a defect on our side". Saying the second for the first sends someone
215
+ // to read a log for a mistake they could correct in two clicks.
216
+ return `this team template cannot be launched as written${names} — fix the template, then spawn again`;
217
+ case "codex_unavailable":
218
+ return "codex is not launchable on this machine, so no agent was started — install codex or set VIBER_CODEX_BIN";
219
+ case "launch_failed":
220
+ return `a member failed to start${names}; the rest of the roster was skipped — the team is incomplete`;
221
+ case "policy_invalid":
222
+ return "this machine's local runner policy is present but invalid, so the spawn was refused";
223
+ case "policy_refused":
224
+ return "this machine's local policy refuses this spawn";
225
+ case "unsafe_template_name":
226
+ return "the template name was rejected as unsafe by the runner";
227
+ case "partial_run":
228
+ return "a prior run of this command already started here and was not relaunched — check the machine before retrying";
229
+ }
230
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.11",
3
+ "version": "0.8.13",
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": {