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.
- package/lib/capabilities.ts +27 -24
- package/lib/coverage_watch.ts +35 -3
- package/lib/gateway_result.ts +7 -6
- package/lib/presence_trigger.ts +112 -0
- package/lib/runner_exec.ts +30 -49
- package/lib/runner_roster.ts +37 -13
- package/lib/runner_stream.ts +28 -12
- package/lib/spawn_reason.ts +7 -84
- package/package.json +1 -1
package/lib/capabilities.ts
CHANGED
|
@@ -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
|
|
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).
|
|
48
|
-
" reviewers). NAMES: each agent is
|
|
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
|
-
"
|
|
61
|
-
" launch-team
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
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:
|
|
80
|
-
"
|
|
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
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
" {\"
|
|
88
|
-
"
|
|
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
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
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
|
-
"
|
|
173
|
-
"
|
|
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");
|
package/lib/coverage_watch.ts
CHANGED
|
@@ -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
|
|
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: () =>
|
|
271
|
+
stop: () => {
|
|
272
|
+
clearTimer(timer);
|
|
273
|
+
clearTimer(changeTimer);
|
|
274
|
+
},
|
|
243
275
|
};
|
|
244
276
|
}
|
package/lib/gateway_result.ts
CHANGED
|
@@ -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`
|
|
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
|
|
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`
|
|
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
|
|
40
|
-
*
|
|
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
|
+
}
|
package/lib/runner_exec.ts
CHANGED
|
@@ -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
|
-
* `
|
|
9
|
-
* no injection)
|
|
10
|
-
* (re-claim/cancel) →
|
|
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,
|
|
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
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
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
|
-
/**
|
|
314
|
-
* shell string), a CURATED env, bounded time + output,
|
|
315
|
-
* timeout. The binary is resolved via VIBE_MASTER_CMD
|
|
316
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
770
|
-
// `
|
|
771
|
-
//
|
|
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
|
|
862
|
-
//
|
|
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
|
|
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
|
|
1033
|
-
: "team
|
|
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
|
|
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 ??
|
|
1103
|
+
runSpawn: deps.runSingleSpawn ?? execFileVibeMasterSpawn,
|
|
1123
1104
|
checkPolicy: (entry) =>
|
|
1124
1105
|
validateAgainstPolicy(
|
|
1125
1106
|
{ ...cmd, env: entry.env, template_spec: { roles: [{ ...entry.role }] } },
|
package/lib/runner_roster.ts
CHANGED
|
@@ -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
|
|
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
|
|
357
|
-
// that finds a beat in flight is skipped: the running one covers it.
|
|
358
|
-
if (beatInFlight) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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 =
|
|
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;
|
package/lib/runner_stream.ts
CHANGED
|
@@ -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
|
|
176
|
-
void sendHeartbeat(auth)
|
|
177
|
-
|
|
178
|
-
revoked
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
}
|
package/lib/spawn_reason.ts
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* reason into a sentence the web can show. The
|
|
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.
|
|
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
|
|
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.
|
|
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": {
|