viber-channel 0.8.30 → 0.8.32

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.
@@ -8,7 +8,8 @@
8
8
  * chooses to call it; the channel instructions carry just a one-line pointer.
9
9
  *
10
10
  * This is the HOME of "how to USE Viber" for every project (TaskMan points here,
11
- * #651). Every claim below was checked against the code at #651; keep it that way.
11
+ * #651). Every claim below was checked against the code at #651 (launch path: #660);
12
+ * keep it that way.
12
13
  *
13
14
  * Pure (no I/O) so it is trivially testable and identical across hosts.
14
15
  */
@@ -44,8 +45,9 @@ export function capabilitiesText(channelVersion: string | null = null): string {
44
45
  " viber-gateway launch-team <template> --prefix <p> [--team <t>] [--env dev|staging]",
45
46
  " [--cwd <dir>]",
46
47
  " → --env defaults to staging (the stable channel: agents register with no trust",
47
- " prompt; use it for working teams). Built-in template: dev-team (dev lead + 2",
48
- " reviewers). NAMES: each agent is <prefix>-<role>, the team is --team, else the",
48
+ " prompt; use it for working teams). <template> is the NAME of one of the owner's",
49
+ " WEB templates, e.g. dev-team (dev lead + 2 reviewers). NAMES: each agent is",
50
+ " <prefix>-<role>, the team is --team, else the",
49
51
  " prefix. Example: template dev-team, --prefix i700 → agents i700-dev-lead,",
50
52
  " i700-review-opus, i700-review-codex, all in team \"i700\".",
51
53
  " ⚠ TRAP — --team DEFAULTS TO THE PREFIX. If you launch a child with a prefix that",
@@ -57,11 +59,13 @@ export function capabilitiesText(channelVersion: string | null = null): string {
57
59
  " the full LABEL <prefix>-<role>: same label = same identity and lock (#293).",
58
60
  " E.g. w651-agent (prefix w651) may launch a role child with --prefix w651: label",
59
61
  " w651-child differs, so that is fine — but pass a distinct --team (w651-child).",
60
- "WHICH \"dev-team\"? The name is resolved per path, not globally:",
61
- " launch-team → the entry of .viber/agent-teams.json with that name if any (it",
62
- " REPLACES the built-in entirely), else the built-in. The web (New team / Add agent)",
63
- " → the owner's database template of that name. Same name, possibly three rosters.",
64
- " When you copy a web template into agent-teams.json, give it a DISTINCT name.",
62
+ "WHERE DO TEMPLATES COME FROM? ONE source (#660): the owner's templates on the web.",
63
+ " launch-team reads <template> from the web at launch time (with the folder's",
64
+ " project token); the web's New team / Add agent use the same templates. No local",
65
+ " file, no built-in, no cache, no fallback: web unreachable, token refused or name",
66
+ " unknown = an error, and NOTHING is launched (an unknown name lists the available",
67
+ " ones).",
68
+ " A template the spawn would refuse (its warnings, see below) is refused up front.",
65
69
  " Output is verbose; only these lines matter:",
66
70
  " team: verdict COMPLETE|INCOMPLET — N member(s)",
67
71
  " member <agent-name>: launch <launch-id> — spec … (<runtime>)",
@@ -76,25 +80,24 @@ export function capabilitiesText(channelVersion: string | null = null): string {
76
80
  " launch-<32 hex> (e.g. launch-03f927dc283f94f521b06ee573a9f582); from Viber app",
77
81
  " 0.7.22 on, the bare 32 hex is accepted too (older: pass the launch- prefix);",
78
82
  " read it on the \"member launch-…: launched\" line.",
79
- " ONLINE DELAY: up to ~45 s after the agent has started, plus the runtime boot",
80
- " (runner beat every 30 s + coverage re-read every 15 s). An empty list_agents right",
83
+ " ONLINE DELAY: about 25-35 s from launch-team to online (one agent ~25 s,",
84
+ " three at once ~35 s), nearly all of it the runtime boot; the runner reports a",
85
+ " new agent within ~4 s of its presence record being written. An older Viber app waits for its",
86
+ " 30 s beat instead: up to ~60 s. An empty list_agents right",
81
87
  " after launching is NOT a failure. Wait simply: call list_agents with",
82
88
  " label_prefix \"<prefix>-\" and online: true every ~10 s, for at most ~60 s; past",
83
89
  " that, check that the Viber app is running.",
84
- "ONE AGENT ALONE = a one-role team. KNOWN LIMIT: launch-team has no --name and no solo",
85
- "shortcut: add a one-role template to .viber/agent-teams.json (merged with the",
86
- "built-ins; an entry with a built-in's name REPLACES it entirely), then launch it:",
87
- " {\"teams\":{\"solo\":{\"roles\":[{\"role\":\"dev\",\"runtime\":\"claude\",",
88
- " \"permission\":\"read-write\",\"rolePrompt\":\"You are the dev of {prefix}.\"}]}}}",
90
+ "ONE AGENT ALONE = a one-role WEB template. KNOWN LIMIT: launch-team has no --name:",
91
+ "the owner creates the template on the web (or an orchestrator agent does, with",
92
+ "team_templates create, ONLY on the owner's explicit request), then you launch it:",
93
+ " spec {\"roles\":[{\"role\":\"dev\",\"runtime\":\"claude\",\"permission\":\"read-write\",",
94
+ " \"rolePrompt\":\"You are the dev of {prefix}.\"}]} named e.g. solo",
89
95
  " viber-gateway launch-team solo --prefix g1 --team g1-solo",
90
96
  " → agent \"g1-dev\", team \"g1-solo\"",
91
97
  " runtime: claude|codex|gemma · permission: read-only|read-write (gemma: read-only",
92
98
  " only; all roles of one template must share one permission) · count optional;",
93
99
  " model/effort: leave them OUT (runtime default) UNLESS the owner explicitly asks",
94
100
  " for one; claude only: model opus|sonnet|haiku|fable, effort low|medium|high|xhigh|max.",
95
- " CLEAN UP: .viber/agent-teams.json is SHARED by everyone launching from that",
96
- " folder — once launched, remove the entry you added (the running agent does not",
97
- " need it; only a new launch-team would).",
98
101
  " (On the web, sidebar ⋯ → Add agent launches ONE named agent without a template edit.)",
99
102
  "",
100
103
  "DISSOLVE (stops the processes, then revokes the proven-stopped instances):",
@@ -159,18 +162,18 @@ export function capabilitiesText(channelVersion: string | null = null): string {
159
162
  " A template in use CAN be deleted: running agents keep running, but a queued web",
160
163
  " launch of it fails (\"template no longer exists\"); so does an edit after the",
161
164
  " launch was queued (template_changed).",
162
- "LAUNCHING A WEB/DB TEMPLATE: launch-team does NOT read these templates (only built-ins",
163
- "+ .viber/agent-teams.json), and agents cannot launch through the API. Either the owner",
164
- "launches it from the web (sidebar ⋯ → New team / Add agent), or you copy its spec",
165
- "(team_templates get) into .viber/agent-teams.json under a name, then launch-team it.",
165
+ "LAUNCHING ONE OF THESE TEMPLATES: launch-team reads exactly these (the web is the only",
166
+ "source, #660) — an edit here is what the next launch-team launches. Agents still cannot",
167
+ "launch through the API: launch-team on a machine, or the owner from the web (sidebar",
168
+ "⋯ → New team / Add agent).",
166
169
  "",
167
170
  "CODEX agents, observed today: their Viber MCP calls, message_agent included, have",
168
171
  "been rejected by their approval policy (#609), so they cannot open an exchange — write",
169
172
  "to them FIRST and read their reply on that DM. Their `gh` has no usable token in",
170
173
  "the sandbox (#525): run gh (labels, PR comments) for them.",
171
174
  "",
172
- "Low level, not relaunchable from the web: `vibe-master spawn --permission <p>",
173
- "[--runtime codex|claude|gemma] [--name <id>]` (`vibe-master --help`).",
175
+ "Never launch agents by calling vibe-master yourself: launch-team (or the web) is the",
176
+ "ONLY launch path (#660), the one the runner supervises and relaunches.",
174
177
  "viber-gateway has no --help: an unknown verb runs the startup command (exit 2",
175
178
  "without --dir; with --dir it runs startup on that registry).",
176
179
  ].join("\n");
@@ -8,7 +8,8 @@
8
8
  * <presenceDir>/<instance_id>.coverage.json
9
9
  * { lease_active: true|false|null, beat_id, conversation_id, written_at }
10
10
  *
11
- * Every 15 s this module re-reads it (a local read, zero network) and decides:
11
+ * Every 15 s this module re-reads it (a local read, zero network) — and at once when the file's
12
+ * mtime changes (#663, checked every second, also local) — and decides:
12
13
  *
13
14
  * - fresh coverage, lease true → ACTIVE;
14
15
  * - lease false → the conversation was taken over: the
@@ -31,7 +32,7 @@
31
32
  * Freshness is judged on the LOCAL clock of the coverage write (`written_at`),
32
33
  * never on the server's beat id.
33
34
  */
34
- import { readFileSync } from "node:fs";
35
+ import { readFileSync, statSync } from "node:fs";
35
36
  import { DEFAULT_MAX_INDETERMINATE_LEASE_BEATS } from "./heartbeat.js";
36
37
  import { runnerStatePath, readRunnerState } from "./runner_registry.js";
37
38
  import { coveragePath } from "./runner_roster.js";
@@ -149,6 +150,26 @@ export interface CoverageWatchOptions {
149
150
  log?: (line: string) => void;
150
151
  setTimer?: (fn: () => void, ms: number) => unknown;
151
152
  clearTimer?: (handle: unknown) => void;
153
+ /** #663: how often the coverage file's mtime is checked (a local stat, no network). */
154
+ changeCheckMs?: number;
155
+ /** #663: the coverage file's mtime in ms, or `null` when absent/unreadable (tests inject it). */
156
+ coverageMtime?: (path: string) => number | null;
157
+ }
158
+
159
+ /**
160
+ * #663 — the agent re-read its coverage every 15 s, so after the runner's beat it could stay
161
+ * PAUSED (online on the web, yet unable to send or receive) for up to 15 s more. A cheap local
162
+ * check of the file's mtime now re-reads it as soon as the runner rewrites it. The 15 s re-read
163
+ * stays: it is what pauses an agent whose coverage went STALE, which no write announces.
164
+ */
165
+ export const COVERAGE_CHANGE_CHECK_MS = 1_000;
166
+
167
+ function fileMtime(path: string): number | null {
168
+ try {
169
+ return statSync(path).mtimeMs;
170
+ } catch {
171
+ return null;
172
+ }
152
173
  }
153
174
 
154
175
  export interface CoverageWatch {
@@ -236,9 +257,20 @@ export function startCoverageWatch(opts: CoverageWatchOptions): CoverageWatch {
236
257
 
237
258
  tick();
238
259
  const timer = setTimer(tick, opts.rereadMs ?? COVERAGE_REREAD_MS);
260
+ const mtimeOf = opts.coverageMtime ?? fileMtime;
261
+ let lastMtime = mtimeOf(covPath);
262
+ const changeTimer = setTimer(() => {
263
+ const m = mtimeOf(covPath);
264
+ if (m === lastMtime) return;
265
+ lastMtime = m;
266
+ if (m !== null) tick();
267
+ }, opts.changeCheckMs ?? COVERAGE_CHANGE_CHECK_MS);
239
268
  return {
240
269
  gate,
241
270
  tick,
242
- stop: () => clearTimer(timer),
271
+ stop: () => {
272
+ clearTimer(timer);
273
+ clearTimer(changeTimer);
274
+ },
243
275
  };
244
276
  }
@@ -4,7 +4,8 @@
4
4
  * ## ⚠ WHY THIS IS A SEPARATE CONTRACT AND NOT AN EXTENSION OF `spawn_reason.ts`
5
5
  *
6
6
  * JP's decision of 2026-08-27: the gateway's result line is a marker of its OWN. ▶ Widening
7
- * `parseSpawnResult` to also accept a nonce-less or differently-shaped payload would have reopened
7
+ * `parseSpawnResult` (the `vibe-master team spawn` reader, removed in #662 once #660 had removed
8
+ * its emitter) to also accept a nonce-less or differently-shaped payload would have reopened
8
9
  * "the last valid marker wins" for `vibe-master`'s marker too — one relaxation, two contracts
9
10
  * weakened. ⚠ So the runner learns a SECOND reader rather than loosening the first.
10
11
  *
@@ -19,8 +20,8 @@
19
20
  * ```
20
21
  *
21
22
  * ▶▶ So a HALF-LAUNCHED team exits `0`, and a consumer deriving success from the exit code reports
22
- * `succeeded` — the browser then says "team spawn completed" while members are missing. ⚠ A
23
- * reason-only marker does not close that: it would be ABSENT on a `0`-exit INCOMPLET team, and an
23
+ * `succeeded` — the browser then said "team spawn completed" (now "team launch completed")
24
+ * while members were missing. ⚠ A reason-only marker does not close that: it would be ABSENT on a `0`-exit INCOMPLET team, and an
24
25
  * absence is indistinguishable from a success.
25
26
  *
26
27
  * ⚠ The gateway's exit codes are NOT the defect and are not touched: `0` there means *a result was
@@ -28,7 +29,7 @@
28
29
  *
29
30
  * ## ▶ THE FALLBACK IS A FAILURE, NEVER A SUCCESS
30
31
  *
31
- * `parseSpawnResult` returns `null` for "no marker" and the caller *degrades to the previous
32
+ * `parseSpawnResult` returned `null` for "no marker" and its caller *degraded to the previous
32
33
  * message* — correct there, because that path's success was already established by the exit code.
33
34
  * ⚠ **Here the exit code cannot establish success**, so a `null` must not be readable as one. Rules
34
35
  * entry 10: a fallback is only legitimate when its value is IMPOSSIBLE to confuse with a measurement.
@@ -36,8 +37,8 @@
36
37
 
37
38
  import { SPAWN_REASONS, type SpawnReason } from "./spawn_reason.js";
38
39
 
39
- /** Mirrors `../../viber-gateway/lib/result_marker.ts`. ⚠ Named separately from `VIBEMASTER_RESULT` so
40
- * neither reader can be satisfied by the other's line. */
40
+ /** Mirrors `../../viber-gateway/lib/result_marker.ts`. ⚠ Named separately from the former
41
+ * `VIBEMASTER_RESULT` so that no reader could be satisfied by the other's line. */
41
42
  const GATEWAY_MARKER = "VIBERGATEWAY_RESULT";
42
43
 
43
44
  /** Bounded at both ends of the pipe, like `vibe-master`'s detail. */
@@ -0,0 +1,112 @@
1
+ /**
2
+ * presence_trigger.ts — #663: the runner beats when an agent APPEARS, not only on its 30 s tick.
3
+ *
4
+ * Measured (plan 663, step-01): an agent's presence record is on disk ~21 s after launch, and the
5
+ * runner then waited for the next tick of its 30 s beat — 3 to 26 s of pure waiting on 8 launches.
6
+ *
7
+ * JP's constraint (2026-10-01): **no more network than today when nothing changes.** So this module
8
+ * is LOCAL only: it re-lists the presence folder every `intervalMs` (a directory read and one stat
9
+ * per record — no request), and calls `onChange` when a record is NEW or REWRITTEN (an agent writes
10
+ * its record at registration, then again when its start time becomes known — the runner skips a
11
+ * record without one, so that rewrite must trigger too). The caller turns `onChange` into ONE extra
12
+ * beat, the same request as the periodic one.
13
+ *
14
+ * - **Deletions do not trigger.** The beat itself deletes the records the server acknowledged as
15
+ * ended; reacting to that would make every such beat schedule another one.
16
+ * - **Coverage files and the `runner/` folder are ignored**: the beat writes them after each
17
+ * acknowledgement, so they must not count as a change either.
18
+ * - **Debounced**: a team of three writes three records within a second — one beat, not three.
19
+ * - The first scan is the BASELINE: records already there when the runner starts are covered by the
20
+ * runner's own start-up beat.
21
+ *
22
+ * ⚠ Why polling and not `fs.watch` (Opus, plan review): on Windows `fs.watch` duplicates events and
23
+ * reports an atomic write (tmp + rename) as a `rename`; a 2 s directory read is cheap and exact.
24
+ */
25
+ import { readdirSync, statSync } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ export const PRESENCE_SCAN_MS = 2_000;
29
+ export const PRESENCE_DEBOUNCE_MS = 500;
30
+
31
+ export interface PresenceTriggerOptions {
32
+ dir: string;
33
+ onChange: () => void;
34
+ intervalMs?: number;
35
+ debounceMs?: number;
36
+ /** Record file name → mtime (ms). `null` = folder unreadable or absent (treated as empty). */
37
+ list?: (dir: string) => Map<string, number> | null;
38
+ setInterval?: (fn: () => void, ms: number) => unknown;
39
+ clearInterval?: (handle: unknown) => void;
40
+ setTimeout?: (fn: () => void, ms: number) => unknown;
41
+ clearTimeout?: (handle: unknown) => void;
42
+ }
43
+
44
+ export interface PresenceTrigger {
45
+ /** One scan now (tests, and nothing else needs it). */
46
+ scan: () => void;
47
+ stop: () => void;
48
+ }
49
+
50
+ /** The agent records of a presence folder: `<instance_id>.json`, never `*.coverage.json`. */
51
+ export function listPresenceRecords(dir: string): Map<string, number> | null {
52
+ let names: string[];
53
+ try {
54
+ names = readdirSync(dir);
55
+ } catch {
56
+ return null;
57
+ }
58
+ const out = new Map<string, number>();
59
+ for (const name of names) {
60
+ if (!name.endsWith(".json") || name.endsWith(".coverage.json")) continue;
61
+ try {
62
+ out.set(name, statSync(join(dir, name)).mtimeMs);
63
+ } catch {
64
+ // Deleted between the listing and the stat: a deletion, which never triggers.
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ export function startPresenceTrigger(opts: PresenceTriggerOptions): PresenceTrigger {
71
+ const list = opts.list ?? listPresenceRecords;
72
+ const setIv = opts.setInterval ?? ((fn: () => void, ms: number) => unrefd(setInterval(fn, ms)));
73
+ const clearIv = opts.clearInterval ?? ((h: unknown) => clearInterval(h as ReturnType<typeof setInterval>));
74
+ const setTo = opts.setTimeout ?? ((fn: () => void, ms: number) => unrefd(setTimeout(fn, ms)));
75
+ const clearTo = opts.clearTimeout ?? ((h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>));
76
+ const debounceMs = opts.debounceMs ?? PRESENCE_DEBOUNCE_MS;
77
+
78
+ let known: Map<string, number> = list(opts.dir) ?? new Map();
79
+ let pending: unknown = null;
80
+ let stopped = false;
81
+
82
+ const scan = (): void => {
83
+ if (stopped) return;
84
+ const now = list(opts.dir) ?? new Map<string, number>();
85
+ let changed = false;
86
+ for (const [name, mtime] of now) {
87
+ if (known.get(name) !== mtime) changed = true;
88
+ }
89
+ known = now;
90
+ if (!changed || pending !== null) return;
91
+ pending = setTo(() => {
92
+ pending = null;
93
+ if (!stopped) opts.onChange();
94
+ }, debounceMs);
95
+ };
96
+
97
+ const timer = setIv(scan, opts.intervalMs ?? PRESENCE_SCAN_MS);
98
+ return {
99
+ scan,
100
+ stop: () => {
101
+ stopped = true;
102
+ clearIv(timer);
103
+ if (pending !== null) clearTo(pending);
104
+ pending = null;
105
+ },
106
+ };
107
+ }
108
+
109
+ function unrefd<T>(h: T): T {
110
+ (h as { unref?: () => void }).unref?.();
111
+ return h;
112
+ }
@@ -5,9 +5,10 @@
5
5
  * Flow (per claimed command): validate the SERVER-VALIDATED role snapshot
6
6
  * against the machine's local DEFAULT-DENY policy (permissions = template ∩
7
7
  * local, never widened — C3) → report `starting` → shell
8
- * `vibe-master team spawn` via an argv ARRAY (execFile, never a shell string —
9
- * no injection) → report `succeeded`/`failed` (fencing-guarded). A 409 report
10
- * (re-claim/cancel) → cooperative abort (don't overwrite a newer outcome — C4).
8
+ * `viber-gateway team` via an argv ARRAY (execFile, never a shell string —
9
+ * no injection), which invokes `vibe-master spawn` once per member → report
10
+ * `succeeded`/`failed` (fencing-guarded). A 409 report (re-claim/cancel) →
11
+ * cooperative abort (don't overwrite a newer outcome — C4).
11
12
  *
12
13
  * Dependency-injected: `runTeamSpawn` (the launcher) and `apiFetch` are
13
14
  * injectable so the flow is unit-testable without shelling or a live server.
@@ -19,7 +20,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
19
20
  import { join } from "node:path";
20
21
  import { cfAccessHeaders } from "./cfAccess.js";
21
22
  import { gatewayNothingToDo, gatewaySpawnOk, parseGatewayResult } from "./gateway_result.js";
22
- import { messageForReason, parseSpawnResult, rosterNames, type SpawnReason } from "./spawn_reason.js";
23
+ import { messageForReason, rosterNames, type SpawnReason } from "./spawn_reason.js";
23
24
  import { identityBaseUrl } from "./base_urls.js";
24
25
  import { recordSpawn, registryPath, type SpawnForRegistry, updateRegistry } from "./runner_registry.js";
25
26
  import { relaunchInstance } from "./runner_relaunch.js";
@@ -246,11 +247,10 @@ export type TeamSpawnRunner = (
246
247
  * ⚠⚠ #590: **the LAUNCHER states whether the desired state was already in place** — the reader
247
248
  * no longer infers it from a bare `reason` string.
248
249
  *
249
- * ▶ The same word reaches the reader from two launchers with two DIFFERENT guards, and the value
250
- * that crossed this boundary carried neither the provenance nor the proof
251
- * (`gwl10-review-codex-2`). Each runner now proves its own claim: the gateway through
252
- * {@link gatewayNothingToDo} (roster coverage **and** verdict), `vibe-master` through
253
- * `classifyCollision`, which emits this word only when NO roster member is missing.
250
+ * ▶ The value that crossed this boundary used to carry neither the provenance nor the proof
251
+ * (`gwl10-review-codex-2`). The launcher now proves its own claim: the gateway through
252
+ * {@link gatewayNothingToDo} (roster coverage **and** verdict). It is the only launcher that
253
+ * makes one since #660 removed `vibe-master team spawn`.
254
254
  *
255
255
  * ⚠ **OPTIONAL, and the reader FAILS CLOSED on absence** (`?? false`): an omitted field is *no
256
256
  * claim*, never *nothing to do*. ▶ This is deliberately unlike the `nonce` above, which is
@@ -310,10 +310,12 @@ export function vibeMasterBaseCmd(source: NodeJS.ProcessEnv = process.env): stri
310
310
  return parts.length > 0 ? parts : ["vibe-master"];
311
311
  }
312
312
 
313
- /** Default launcher: shell vibe-master via execFile with an ARGV ARRAY (never a
314
- * shell string), a CURATED env, bounded time + output, and process-tree kill on
315
- * timeout. The binary is resolved via VIBE_MASTER_CMD (default `vibe-master`). */
316
- export const execFileTeamSpawn: TeamSpawnRunner = (args, nonce) => {
313
+ /** The single-agent launcher (relaunch path): shell `vibe-master spawn` via execFile
314
+ * with an ARGV ARRAY (never a shell string), a CURATED env, bounded time + output,
315
+ * and process-tree kill on timeout. The binary is resolved via VIBE_MASTER_CMD
316
+ * (default `vibe-master`). `spawn` writes no outcome marker, so the exit code is
317
+ * the whole verdict — the gateway path, which has one, is {@link execFileGatewayTeamSpawn}. */
318
+ export const execFileVibeMasterSpawn = (args: string[]): Promise<{ ok: boolean; recap: string }> => {
317
319
  const base = vibeMasterBaseCmd();
318
320
  const bin = base[0] as string;
319
321
  return new Promise((resolve) => {
@@ -322,27 +324,7 @@ export const execFileTeamSpawn: TeamSpawnRunner = (args, nonce) => {
322
324
  [...base.slice(1), ...args],
323
325
  { timeout: 5 * 60 * 1000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, env: buildChildEnv() },
324
326
  (err, stdout, stderr) => {
325
- // #496: read the outcome marker from RAW stderr, BEFORE redaction and
326
- // before the 4 000-char cut. The marker is written last, so it is the
327
- // first thing `.slice()` drops on a real (verbose) roster — parsing the
328
- // recap would pass every test and fail every actual spawn. Redaction can
329
- // also mangle the JSON. Both hazards disappear by reading first.
330
- const parsed = parseSpawnResult(stderr ?? "", { nonce });
331
- const recap = redactSecrets(`${stdout}\n${stderr}`.trim()).slice(0, 4000);
332
- resolve({
333
- ok: !err,
334
- // ▶ #590: vibe-master's OWN guarantee, stated by the launcher that holds it.
335
- // ⚠ MEASURED (`vibe-master/lib/teams.ts`, `classifyCollision`): this word is emitted only
336
- // when `missing.length === 0` — every roster name live — and a non-colliding call returns
337
- // `null` before it, so an empty roster cannot produce it either. A partial team is
338
- // `partial_team` and stays a failure. ▶ **The same roster-coverage property as the
339
- // gateway's, by different code** — so promoting only the gateway path would turn a
340
- // CORRECT de-duplication here back into a red, which is the defect JP arbitrated away.
341
- nothingToDo: parsed?.reason === "already_running",
342
- recap,
343
- ...(parsed?.reason ? { reason: parsed.reason } : {}),
344
- ...(parsed?.detail ? { detail: parsed.detail } : {}),
345
- });
327
+ resolve({ ok: !err, recap: redactSecrets(`${stdout}\n${stderr}`.trim()).slice(0, 4000) });
346
328
  },
347
329
  );
348
330
  });
@@ -522,7 +504,7 @@ export function buildGatewayTeamArgs(cmd: {
522
504
  /**
523
505
  * The gateway launcher. ⚠⚠ **`ok` is derived from the MARKER, never from the exit
524
506
  * code**, and that is the whole reason this function exists beside
525
- * {@link execFileTeamSpawn}.
507
+ * {@link execFileVibeMasterSpawn}.
526
508
  *
527
509
  * ```
528
510
  * viber-gateway/lib/team_spawn_command.ts:358
@@ -530,7 +512,7 @@ export function buildGatewayTeamArgs(cmd: {
530
512
  * ```
531
513
  *
532
514
  * ▶▶ So `ok = !err` would report `succeeded` on a HALF-LAUNCHED team, and the
533
- * browser would say *team spawn completed* while members are missing. ⚠ The
515
+ * browser would say *team launch completed* while members are missing. ⚠ The
534
516
  * gateway's exit codes are right — `0` means *a result was produced* — and are not
535
517
  * touched; what changes is that this consumer stops reading that as *the team is
536
518
  * there*.
@@ -756,7 +738,7 @@ async function report(
756
738
 
757
739
  /**
758
740
  * Execute one already-claimed command. Validates against local policy, reports
759
- * starting → runs the team spawn → reports the outcome (fencing-guarded). A 409
741
+ * starting → runs the team launch → reports the outcome (fencing-guarded). A 409
760
742
  * on the `starting` report means the claim is no longer ours (re-claim/cancel)
761
743
  * → cooperative abort BEFORE spawning (never launch on a lost claim). Returns
762
744
  * the terminal status the runner recorded (or "aborted").
@@ -766,11 +748,9 @@ export async function executeClaim(
766
748
  cmd: ClaimedCommand,
767
749
  deps: ExecDeps = {},
768
750
  ): Promise<"succeeded" | "failed" | "aborted"> {
769
- // ⚠⚠ THE DEFAULT IS THE GATEWAY NOW — that swap IS the junction, and leaving
770
- // `execFileTeamSpawn` as the default behind a flag would have been a mechanism
771
- // delivered without a junction, the exact shape this workshop has paid for twice.
772
- // ▶ `execFileTeamSpawn` stays EXPORTED: it is still the launcher for anything that
773
- // talks to `vibe-master` directly, and its witnesses keep measuring that contract.
751
+ // ⚠⚠ THE DEFAULT IS THE GATEWAY — that swap IS the junction (#590). The only
752
+ // direct `vibe-master` launcher left is the single-agent relaunch
753
+ // ({@link execFileVibeMasterSpawn}).
774
754
  const runTeamSpawn = deps.runTeamSpawn ?? execFileGatewayTeamSpawn;
775
755
  const fetchImpl = deps.apiFetch ?? fetch;
776
756
  if (cmd.kind === "relaunch") return executeRelaunchClaim(auth, cmd, deps, fetchImpl);
@@ -858,8 +838,9 @@ export async function executeClaim(
858
838
  if (startingStatus < 200 || startingStatus >= 300) return "aborted";
859
839
 
860
840
  // P1-1: execute EXACTLY the server-validated snapshot. Serialize the already-
861
- // validated roles to a temp spec file and invoke `team spawn --spec-file` — so
862
- // vibe-master runs THOSE roles, never a name-resolved local template. Serialize
841
+ // validated roles to a temp spec file and hand it to `viber-gateway team
842
+ // --spec-file` — so the gateway launches THOSE roles, never a name-resolved
843
+ // template. Serialize
863
844
  // the validated object verbatim (no role rebuild). Cleaned up in `finally`.
864
845
  const spec = { name: cmd.template_name, roles: cmd.template_spec.roles };
865
846
  const { path: specPath, cleanup } = (deps.writeSpec ?? fsWriteSpec())(spec);
@@ -1002,7 +983,7 @@ export async function executeClaim(
1002
983
  auth,
1003
984
  cmd.id,
1004
985
  ok
1005
- ? { fencing_token: cmd.fencing_token, status: "succeeded", result: { message: "team spawn completed", log: logPath } }
986
+ ? { fencing_token: cmd.fencing_token, status: "succeeded", result: { message: "team launch completed", log: logPath } }
1006
987
  : nothingToDo
1007
988
  ? {
1008
989
  fencing_token: cmd.fencing_token,
@@ -1029,8 +1010,8 @@ export async function executeClaim(
1029
1010
  logRef,
1030
1011
  )
1031
1012
  : logRef
1032
- ? `team spawn failed — see ${logRef} on that machine`
1033
- : "team spawn failed, and the local log could not be written on that machine",
1013
+ ? `team launch failed — see ${logRef} on that machine`
1014
+ : "team launch failed, and the local log could not be written on that machine",
1034
1015
  ...(reason && !nameUnconfirmed ? { reason } : {}),
1035
1016
  },
1036
1017
  fetchImpl,
@@ -1079,7 +1060,7 @@ export async function reconcileOnce(auth: RunnerAuthLite, deps: ExecDeps = {}):
1079
1060
  /**
1080
1061
  * #627 step-07 — a RELAUNCH command: one agent, from this runner's registry. The
1081
1062
  * decision (refuse / re-attach / launch) is `relaunchInstance`'s; this reports it with
1082
- * the same fencing-guarded reports as a team spawn. No automatic retry on failure.
1063
+ * the same fencing-guarded reports as a team launch. No automatic retry on failure.
1083
1064
  */
1084
1065
  async function executeRelaunchClaim(
1085
1066
  auth: RunnerAuthLite,
@@ -1119,7 +1100,7 @@ async function executeRelaunchClaim(
1119
1100
  identity: runnerIdentity(auth),
1120
1101
  ...(deps.relaunchLockDir !== undefined ? { lockDir: deps.relaunchLockDir } : {}),
1121
1102
  ...(deps.relaunchEnumerate !== undefined ? { enumerate: deps.relaunchEnumerate } : {}),
1122
- runSpawn: deps.runSingleSpawn ?? ((args) => execFileTeamSpawn(args, randomBytes(8).toString("hex"))),
1103
+ runSpawn: deps.runSingleSpawn ?? execFileVibeMasterSpawn,
1123
1104
  checkPolicy: (entry) =>
1124
1105
  validateAgainstPolicy(
1125
1106
  { ...cmd, env: entry.env, template_spec: { roles: [{ ...entry.role }] } },
@@ -350,23 +350,47 @@ export interface RosterBeatDeps {
350
350
  * A failed or non-2xx beat changes NOTHING on disk: the coverage then expires on its
351
351
  * own and the ended records go again next beat.
352
352
  */
353
- export async function runRosterBeat(auth: RosterBeatAuth, deps: RosterBeatDeps = {}): Promise<"ok" | "revoked"> {
353
+ export function runRosterBeat(
354
+ auth: RosterBeatAuth,
355
+ deps: RosterBeatDeps = {},
356
+ opts: { queueIfBusy?: boolean } = {},
357
+ ): Promise<"ok" | "revoked"> {
354
358
  // SINGLE-FLIGHT. The enumeration alone can take up to 30 s — one heartbeat period —
355
359
  // so an overlapping beat is ordinary, and two answers arriving in reverse order
356
- // would rewrite an older coverage over a newer one (Codex, step-03 review). A tick
357
- // that finds a beat in flight is skipped: the running one covers it.
358
- if (beatInFlight) {
359
- noteOnce("a heartbeat was skipped: the previous one is still in flight", deps.log ?? ((l: string) => process.stderr.write(`${l}\n`)));
360
- return "ok";
361
- }
362
- beatInFlight = true;
363
- try {
364
- return await rosterBeatOnce(auth, deps);
365
- } finally {
366
- beatInFlight = false;
360
+ // would rewrite an older coverage over a newer one (Codex, step-03 review). A
361
+ // PERIODIC tick that finds a beat in flight is skipped: the running one covers it.
362
+ if (beatInFlight !== null) {
363
+ if (!opts.queueIfBusy) {
364
+ noteOnce("a heartbeat was skipped: the previous one is still in flight", deps.log ?? ((l: string) => process.stderr.write(`${l}\n`)));
365
+ return Promise.resolve("ok");
366
+ }
367
+ // #663: a TRIGGERED beat (a presence record appeared or changed) is not dropped: the beat in
368
+ // flight may have collected its roster BEFORE that record existed, so skipping it would leave
369
+ // the agent waiting for the next 30 s tick — the very delay the trigger removes. ONE follow-up
370
+ // beat at most, whatever the number of triggers while busy (Codex, plan review).
371
+ // ⚠ Chained on the beat's OUTCOME, success or rejection (Opus + Codex review): chained on
372
+ // success only, a rejected beat left `queuedBeat` pinned to a dead promise for good, and every
373
+ // later trigger silently lost its follow-up. A `revoked` answer cancels the follow-up (Codex).
374
+ const followUp = (previous: "ok" | "revoked" | undefined): Promise<"ok" | "revoked"> => {
375
+ queuedBeat = null;
376
+ if (previous === "revoked") return Promise.resolve("revoked");
377
+ return runRosterBeat(auth, deps, { queueIfBusy: true });
378
+ };
379
+ queuedBeat ??= beatInFlight.then(followUp, () => followUp(undefined));
380
+ return queuedBeat;
367
381
  }
382
+ const run = (async () => {
383
+ try {
384
+ return await rosterBeatOnce(auth, deps);
385
+ } finally {
386
+ beatInFlight = null;
387
+ }
388
+ })();
389
+ beatInFlight = run;
390
+ return run;
368
391
  }
369
- let beatInFlight = false;
392
+ let beatInFlight: Promise<"ok" | "revoked"> | null = null;
393
+ let queuedBeat: Promise<"ok" | "revoked"> | null = null;
370
394
  let unappliedBeats = 0;
371
395
  /** #627 (decision A): pending beats before the runner says the handover is slow. */
372
396
  export const HANDOVER_WARN_AFTER = 10;
@@ -19,8 +19,10 @@ import { randomBytes } from "node:crypto";
19
19
  import { join } from "node:path";
20
20
  import { cfAccessHeaders } from "./cfAccess.js";
21
21
  import { isTrustedViberOrigin } from "./urls.js";
22
- import { type ExecDeps, reconcileOnce } from "./runner_exec.js";
22
+ import { type ExecDeps, reconcileOnce, runnerIdentity } from "./runner_exec.js";
23
23
  import { runRosterBeat } from "./runner_roster.js";
24
+ import { presenceDir } from "./presence_record.js";
25
+ import { startPresenceTrigger } from "./presence_trigger.js";
24
26
 
25
27
  /** Remove leftover temp spec files (`.viber/runner-specs/`) from a prior crash
26
28
  * at daemon start — they may hold sensitive rolePrompts (Codex P1-1). The
@@ -127,8 +129,8 @@ async function safeReconcile(auth: RunnerAuth, deps: ExecDeps = {}): Promise<voi
127
129
  *
128
130
  * #627: the beat now carries the machine's ROSTER — the only presence signal of
129
131
  * the agents this runner vouches for (`runner_roster.ts`). */
130
- async function sendHeartbeat(auth: RunnerAuth): Promise<"ok" | "revoked"> {
131
- return runRosterBeat(auth, { headers: cfAccessHeaders });
132
+ async function sendHeartbeat(auth: RunnerAuth, queueIfBusy = false): Promise<"ok" | "revoked"> {
133
+ return runRosterBeat(auth, { headers: cfAccessHeaders }, { queueIfBusy });
132
134
  }
133
135
 
134
136
  export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
@@ -172,15 +174,28 @@ export async function runPersistentRunnerStream(
172
174
  // Shared abort so a heartbeat-detected revocation tears down the live SSE now,
173
175
  // instead of waiting for the next reconnect.
174
176
  let currentAbort: AbortController | null = null;
175
- const heartbeatTimer = setInterval(() => {
176
- void sendHeartbeat(auth).then((r) => {
177
- if (r === "revoked") {
178
- revoked = true;
179
- stopped = true;
180
- currentAbort?.abort();
181
- }
182
- });
183
- }, HEARTBEAT_INTERVAL_MS);
177
+ const beat = (queueIfBusy: boolean): void => {
178
+ void sendHeartbeat(auth, queueIfBusy)
179
+ .then((r) => {
180
+ if (r === "revoked") {
181
+ revoked = true;
182
+ stopped = true;
183
+ currentAbort?.abort();
184
+ }
185
+ })
186
+ // A beat that throws (process enumeration, a disk read) is logged, never an unhandled
187
+ // rejection — and the next tick or trigger beats again (Opus review).
188
+ .catch((err) => process.stderr.write(`[runner] heartbeat error (non-fatal): ${String(err)}\n`));
189
+ };
190
+ const heartbeatTimer = setInterval(() => beat(false), HEARTBEAT_INTERVAL_MS);
191
+ // #663: one beat at START (agents already on the machine are covered at once, not after up to
192
+ // 30 s), then one more whenever a presence record appears or is rewritten — a LOCAL folder scan,
193
+ // no request unless something changed. The 30 s tick is unchanged.
194
+ beat(false);
195
+ const presenceTrigger = startPresenceTrigger({
196
+ dir: presenceDir(runnerIdentity(auth)),
197
+ onChange: () => beat(true),
198
+ });
184
199
  // Reconcile timer is a RE-JITTERED recursive setTimeout (not setInterval): each
185
200
  // tick draws a fresh crypto jitter so a fleet never re-synchronizes (Codex C6).
186
201
  let reconcileTimer: ReturnType<typeof setTimeout>;
@@ -231,6 +246,7 @@ export async function runPersistentRunnerStream(
231
246
  }
232
247
  } finally {
233
248
  clearInterval(heartbeatTimer);
249
+ presenceTrigger.stop();
234
250
  clearTimeout(reconcileTimer!);
235
251
  }
236
252
  }
@@ -7,18 +7,19 @@
7
7
  * had nowhere to go. The cause existed — it was on the runner's stderr — and was
8
8
  * thrown away here.
9
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,
10
+ * The emitter of a team outcome is `viber-gateway/lib/result_marker.ts`
11
+ * (`VIBERGATEWAY_RESULT`, read by `gateway_result.ts`); the reasons below are its
12
+ * vocabulary. This module turns a reason into a sentence the web can show. The
13
+ * runner's OWN refusals (policy,
13
14
  * unsafe input, a prior partial run) carry reasons from the same vocabulary, so
14
15
  * the web never has to speak two languages.
15
16
  */
16
17
 
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
18
+ /** Reasons a spawn can be refused. The launcher's vocabulary (emitted by
19
+ * `viber-gateway/lib/result_marker.ts`) plus the four the runner produces itself. The API keeps its own allowlist: an unknown
19
20
  * reason is dropped there, never stored raw — it comes from a client machine. */
20
21
  export const SPAWN_REASONS = [
21
- // Emitted by vibe-master
22
+ // Emitted by the launcher (viber-gateway)
22
23
  "already_running",
23
24
  "partial_team",
24
25
  "template_invalid",
@@ -37,18 +38,6 @@ export const SPAWN_REASONS = [
37
38
 
38
39
  export type SpawnReason = (typeof SPAWN_REASONS)[number];
39
40
 
40
- const MARKER = "VIBEMASTER_RESULT";
41
-
42
- /** Detail is diagnostic context, never shown raw to a user (the server bounds it
43
- * again and the web renders from the REASON). Bounded here too so a runaway
44
- * child cannot push a megabyte into a report. */
45
- const DETAIL_MAX = 300;
46
-
47
- export interface ParsedSpawnResult {
48
- reason: SpawnReason;
49
- detail?: string;
50
- }
51
-
52
41
  /**
53
42
  * Keep only the names that belong to THIS command's roster.
54
43
  *
@@ -96,72 +85,6 @@ export function rosterNames(
96
85
  return names;
97
86
  }
98
87
 
99
- /**
100
- * Extract the outcome marker from RAW child stderr.
101
- *
102
- * Read this before the recap is built, never after: the recap is redacted and
103
- * then cut to 4 000 characters, and the marker is written LAST — exactly the end
104
- * that the cut removes. A four-member roster overflows that budget easily, so
105
- * parsing the recap would work in tests and fail on a real spawn.
106
- *
107
- * Scans for the LAST line that starts with the marker AND parses AND carries a
108
- * known reason — not simply the last line. On `launch_failed` the marker is
109
- * written after child processes have inherited this same stderr, so trailing
110
- * child output can follow it; and a line quoting the marker inside other text is
111
- * ignored because the prefix must start the line.
112
- *
113
- * Returns null when there is no marker (an older vibe-master, or output lost to
114
- * a maxBuffer overflow) — the caller then behaves exactly as before.
115
- */
116
- export type MarkerTrust =
117
- /** Believe only a marker echoing this nonce (the runner path — the default). */
118
- | { nonce: string }
119
- /** No emitter check at all. Named so a caller cannot fall into it by omission
120
- * (Opus review): an optional nonce silently skipped would reopen "last valid
121
- * marker wins" with no test turning red. Only for reading output nobody
122
- * else could have written into — a human running the CLI, or a unit test. */
123
- | { trustUnauthenticated: true };
124
-
125
- export function parseSpawnResult(
126
- rawStderr: string,
127
- trust: MarkerTrust,
128
- ): ParsedSpawnResult | null {
129
- const expectedNonce = "nonce" in trust ? trust.nonce : undefined;
130
- if (expectedNonce !== undefined && expectedNonce.length === 0) {
131
- throw new Error("parseSpawnResult: empty nonce — pass {trustUnauthenticated:true} to skip the check deliberately");
132
- }
133
- const known = new Set<string>(SPAWN_REASONS);
134
- for (const line of rawStderr.split(/\r?\n/).reverse()) {
135
- if (!line.startsWith(`${MARKER} `)) continue;
136
- let parsed: unknown;
137
- try {
138
- parsed = JSON.parse(line.slice(MARKER.length + 1));
139
- } catch {
140
- continue; // truncated or interleaved — keep looking further back
141
- }
142
- if (typeof parsed !== "object" || parsed === null) continue;
143
- const { reason, detail, nonce } = parsed as {
144
- reason?: unknown;
145
- detail?: unknown;
146
- nonce?: unknown;
147
- };
148
- // Emitter proof. Spawned agents inherit this stderr, and an agent working on
149
- // THIS repo prints this very contract in its own output — "the last valid
150
- // marker wins" would let such a line overrule the real outcome and turn a
151
- // launch_failed into a neutral already_running. Only the process we handed
152
- // the nonce to can echo it.
153
- if (expectedNonce && nonce !== expectedNonce) continue;
154
- if (typeof reason !== "string" || !known.has(reason)) continue;
155
- return {
156
- reason: reason as SpawnReason,
157
- ...(typeof detail === "string" && detail.length > 0
158
- ? { detail: detail.slice(0, DETAIL_MAX) }
159
- : {}),
160
- };
161
- }
162
- return null;
163
- }
164
-
165
88
  /**
166
89
  * The sentence the web shows. Built from the REASON. Where agent names carry the
167
90
  * actionable part ("which member is missing"), they go through
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.30",
3
+ "version": "0.8.32",
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": {