viber-channel 0.8.16 → 0.8.19
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/agent_tools.ts +28 -4
- package/lib/auth.ts +69 -3
- package/lib/bridge_core.ts +172 -2
- package/lib/bridge_tool_host.ts +14 -3
- package/lib/claude_tool_defs.ts +16 -2
- package/lib/connect.ts +23 -8
- package/lib/gateway_result.ts +266 -0
- package/lib/instance.ts +10 -0
- package/lib/messages.ts +8 -1
- package/lib/peers.ts +62 -7
- package/lib/runner_exec.ts +333 -8
- package/lib/spawn_reason.ts +23 -0
- package/lib/urls.ts +2 -0
- package/package.json +1 -1
- package/viber-channel.ts +1 -1
- package/viber-codex-bridge.ts +113 -4
package/lib/agent_tools.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* MCP tool-result shape. It does NOT hold state.
|
|
18
18
|
*/
|
|
19
19
|
import { postMessage, parseArtifact, type Artifact } from "./messages.js";
|
|
20
|
-
import { listPeersAuto, openDm, type OpenDmResult } from "./peers.js";
|
|
20
|
+
import { listPeersAuto, openDm, type OpenDmResult, type PeerFilters } from "./peers.js";
|
|
21
21
|
import { ConversationTokenExpiredError } from "./messages.js";
|
|
22
22
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
23
23
|
|
|
@@ -98,12 +98,27 @@ function errorText(s: string): AgentToolResult {
|
|
|
98
98
|
* entry then carries a `project` field); a regular instance transparently
|
|
99
99
|
* falls back to its own project (unchanged behaviour, no `project` field).
|
|
100
100
|
*/
|
|
101
|
-
export async function listAgents(
|
|
101
|
+
export async function listAgents(
|
|
102
|
+
ctx: AgentToolsContext,
|
|
103
|
+
args: Record<string, unknown> = {},
|
|
104
|
+
): Promise<AgentToolResult> {
|
|
102
105
|
if (!ctx.instanceToken()) {
|
|
103
106
|
return errorText("Channel not ready: no instance identity yet.");
|
|
104
107
|
}
|
|
108
|
+
// #501 step-05 — optional filters, applied SERVER-side. Without them a
|
|
109
|
+
// dev-lead looking for its two reviewers pulls every agent the project ever
|
|
110
|
+
// created (30 rows for 5 live ones, measured), in a loop.
|
|
111
|
+
const filters: PeerFilters = {};
|
|
112
|
+
if (args.online === true) filters.online = true;
|
|
113
|
+
if (typeof args.label_prefix === "string" && args.label_prefix !== "") {
|
|
114
|
+
filters.labelPrefix = args.label_prefix;
|
|
115
|
+
}
|
|
105
116
|
try {
|
|
106
|
-
const { peers, scope } = await listPeersAuto(
|
|
117
|
+
const { peers, scope, presenceStatus } = await listPeersAuto(
|
|
118
|
+
ctx.baseUrl(),
|
|
119
|
+
ctx.instanceToken(),
|
|
120
|
+
filters,
|
|
121
|
+
);
|
|
107
122
|
// Project only what the model needs to pick a peer (drop last_seen /
|
|
108
123
|
// active_conversation_id — available over the wire if a future tool needs them).
|
|
109
124
|
const summary = peers.map((p) => ({
|
|
@@ -114,13 +129,22 @@ export async function listAgents(ctx: AgentToolsContext): Promise<AgentToolResul
|
|
|
114
129
|
// Present only in the user-scoped (orchestrator) listing.
|
|
115
130
|
...(p.project_name !== undefined ? { project: p.project_name } : {}),
|
|
116
131
|
}));
|
|
132
|
+
// An `online` filter under an unreadable presence source would come back
|
|
133
|
+
// empty and read as "nobody is alive" — the false green in listing form.
|
|
134
|
+
// The server drops the filter in that case; say so rather than let the
|
|
135
|
+
// caller believe it was applied.
|
|
136
|
+
const presenceWarning =
|
|
137
|
+
filters.online === true && presenceStatus === "unavailable"
|
|
138
|
+
? "WARNING: the presence source is unavailable, so the `online` filter was NOT applied — " +
|
|
139
|
+
"`online` values below are unknown, not observed.\n\n"
|
|
140
|
+
: "";
|
|
117
141
|
const body =
|
|
118
142
|
summary.length === 0
|
|
119
143
|
? scope === "user"
|
|
120
144
|
? "No other agents are currently registered in any of your projects."
|
|
121
145
|
: "No other agents are currently registered in this project."
|
|
122
146
|
: JSON.stringify(summary, null, 2);
|
|
123
|
-
return text(body);
|
|
147
|
+
return text(`${presenceWarning}${body}`);
|
|
124
148
|
} catch (err) {
|
|
125
149
|
return errorText(`list_agents failed: ${String(err)}`);
|
|
126
150
|
}
|
package/lib/auth.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { isAbsolute, join } from "node:path";
|
|
2
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
export interface AuthJson {
|
|
5
5
|
schema_version: number;
|
|
@@ -63,15 +63,81 @@ export function isDevBackend(
|
|
|
63
63
|
* silently authenticated against the dev backend with STAGING creds (`-32000`)
|
|
64
64
|
* because only `VIBER_BASE_URL` was set and the auth file defaulted to staging.
|
|
65
65
|
*/
|
|
66
|
-
export function authFilePath(
|
|
66
|
+
export function authFilePath(
|
|
67
|
+
cwd: string = process.cwd(),
|
|
68
|
+
baseUrl: string = process.env.VIBER_BASE_URL ?? "",
|
|
69
|
+
): string {
|
|
70
|
+
const qa = isQaBackend(baseUrl);
|
|
67
71
|
const override = process.env.VIBER_AUTH_FILE;
|
|
68
72
|
if (override !== undefined && override.trim() !== "") {
|
|
73
|
+
assertAuthFileMatchesBackend(basename(override), qa);
|
|
69
74
|
return isAbsolute(override) ? override : join(cwd, override);
|
|
70
75
|
}
|
|
71
|
-
const file = isDevBackend() ? "dev.auth.json" : "auth.json";
|
|
76
|
+
const file = isDevBackend(baseUrl) ? "dev.auth.json" : qa ? QA_AUTH_FILE : "auth.json";
|
|
72
77
|
return join(cwd, ".viber", file);
|
|
73
78
|
}
|
|
74
79
|
|
|
80
|
+
/**
|
|
81
|
+
* #613: the auth file `connect` must write for a claim URL. The CLAIM decides
|
|
82
|
+
* for qa: `connect https://viber-qa.dgypx.dev/connect/<id>` run with no
|
|
83
|
+
* VIBER_BASE_URL would otherwise derive the file from the (empty) env and write
|
|
84
|
+
* qa credentials into `auth.json`, overwriting STAGING's. A set env that
|
|
85
|
+
* contradicts the claim (qa vs not qa) is refused. Non-qa claims keep the
|
|
86
|
+
* pre-#613 behavior (file derived from the env) unchanged.
|
|
87
|
+
*/
|
|
88
|
+
export function connectAuthPath(
|
|
89
|
+
claimBaseUrl: string,
|
|
90
|
+
cwd: string = process.cwd(),
|
|
91
|
+
envBaseUrl: string = process.env.VIBER_BASE_URL ?? "",
|
|
92
|
+
): string {
|
|
93
|
+
const claimQa = isQaBackend(claimBaseUrl);
|
|
94
|
+
if (envBaseUrl.trim() !== "" && claimQa !== isQaBackend(envBaseUrl)) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`claim URL ${claimBaseUrl} and VIBER_BASE_URL=${envBaseUrl} disagree on the qa backend; refusing to write credentials`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return authFilePath(cwd, claimQa ? claimBaseUrl : envBaseUrl);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** #613: the qa web host. Matched EXACTLY (hostname), never by substring. */
|
|
103
|
+
export const QA_WEB_HOST = "viber-qa.dgypx.dev";
|
|
104
|
+
export const QA_AUTH_FILE = "qa.auth.json";
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* #613: true only when `VIBER_BASE_URL` is the qa web host. Same raw-env
|
|
108
|
+
* classification rule as {@link isDevBackend} (never the API transport host),
|
|
109
|
+
* but an exact hostname match: a substring test would let any host containing
|
|
110
|
+
* "qa" read qa credentials, or a qa host fall through to STAGING's auth.json.
|
|
111
|
+
*/
|
|
112
|
+
export function isQaBackend(
|
|
113
|
+
baseUrl: string = process.env.VIBER_BASE_URL ?? "",
|
|
114
|
+
): boolean {
|
|
115
|
+
try {
|
|
116
|
+
return new URL(baseUrl).hostname === QA_WEB_HOST;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* #613: a `VIBER_AUTH_FILE` override still wins, but a CONTRADICTORY one is
|
|
124
|
+
* refused instead of resolved in silence: qa credentials against another
|
|
125
|
+
* backend, or another env's well-known file against the qa backend. Custom
|
|
126
|
+
* file names stay allowed on both sides.
|
|
127
|
+
*/
|
|
128
|
+
function assertAuthFileMatchesBackend(file: string, qa: boolean): void {
|
|
129
|
+
if (!qa && file === QA_AUTH_FILE) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`VIBER_AUTH_FILE points at ${QA_AUTH_FILE} but VIBER_BASE_URL is not https://${QA_WEB_HOST}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (qa && (file === "auth.json" || file === "dev.auth.json")) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`VIBER_BASE_URL is the qa backend but VIBER_AUTH_FILE points at ${file} (another env's credentials)`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
75
141
|
export function loadAuth(cwd: string = process.cwd()): AuthJson {
|
|
76
142
|
const path = authFilePath(cwd);
|
|
77
143
|
let raw: string;
|
package/lib/bridge_core.ts
CHANGED
|
@@ -306,11 +306,13 @@ export async function postBridgeReply(opts: {
|
|
|
306
306
|
ownPostedIds: Set<string>;
|
|
307
307
|
signal?: AbortSignal;
|
|
308
308
|
logPrefix: string;
|
|
309
|
+
/** Fetch seam (#606) — see postMessage. Defaults to the global fetch. */
|
|
310
|
+
fetchImpl?: typeof fetch;
|
|
309
311
|
}): Promise<void> {
|
|
310
312
|
const { baseUrl, runtime, text, ownPostedIds, signal, logPrefix } = opts;
|
|
311
313
|
let result;
|
|
312
314
|
try {
|
|
313
|
-
result = await postMessage(baseUrl, runtime.id, runtime.token, text, undefined, signal);
|
|
315
|
+
result = await postMessage(baseUrl, runtime.id, runtime.token, text, undefined, signal, opts.fetchImpl ?? fetch);
|
|
314
316
|
} catch (err) {
|
|
315
317
|
if (err instanceof ConversationTokenExpiredError) {
|
|
316
318
|
throw new BridgeShutdownError("conversation token expired", 3);
|
|
@@ -345,6 +347,93 @@ export function decideTurnPostAction(opts: {
|
|
|
345
347
|
return "auto-post";
|
|
346
348
|
}
|
|
347
349
|
|
|
350
|
+
/** Max length of the runtime cause quoted verbatim in a channel failure notice. */
|
|
351
|
+
const FAILURE_CAUSE_MAX = 400;
|
|
352
|
+
|
|
353
|
+
/** Transport degradation observed during a turn (#606 step-02, codex only). */
|
|
354
|
+
export type TurnDegradation = { events: number; lastStatus?: number };
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* #606 — the text a bridge posts on the CHANNEL when a turn fails.
|
|
358
|
+
*
|
|
359
|
+
* The whole point of the issue is that a failed turn was written to stderr only,
|
|
360
|
+
* so the model filled the silence with an INVENTED cause. This text therefore
|
|
361
|
+
* quotes what the runtime said, VERBATIM and truncated, and adds nothing of its
|
|
362
|
+
* own: no classification, no advice, no guess. Pure (no clock, no network) so it
|
|
363
|
+
* is testable without a harness.
|
|
364
|
+
*/
|
|
365
|
+
export function failureNoticeText(err: unknown, degradation?: TurnDegradation): string {
|
|
366
|
+
const raw = safeErrorMessage(err).replace(/\s+/g, " ").trim();
|
|
367
|
+
const cause = raw.length > FAILURE_CAUSE_MAX ? `${raw.slice(0, FAILURE_CAUSE_MAX)}...` : raw || "(aucun detail)";
|
|
368
|
+
let text = `Tour echoue, aucune reponse produite. Cause rapportee par le runtime: ${cause}`;
|
|
369
|
+
if (degradation && degradation.events > 0) {
|
|
370
|
+
const status = degradation.lastStatus ? `, dernier statut HTTP ${degradation.lastStatus}` : "";
|
|
371
|
+
text += ` (transport degrade: ${degradation.events} evenement(s) avant l'echec${status})`;
|
|
372
|
+
}
|
|
373
|
+
return text;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* #606 — the dedup signature of a failure cause.
|
|
378
|
+
*
|
|
379
|
+
* A blown quota fails EVERY subsequent turn: without dedup the fix that makes a
|
|
380
|
+
* failure speak would flood the channel. Two failures of the same cause must
|
|
381
|
+
* therefore yield the SAME key, so the volatile identifiers a runtime stamps on
|
|
382
|
+
* each attempt (turnId, threadId, cf-ray, timestamps, long hex ids) are stripped.
|
|
383
|
+
*
|
|
384
|
+
* The degradation COUNT is deliberately reduced to its presence, never its value:
|
|
385
|
+
* keying on the number would make every different N a "new cause" and re-open the
|
|
386
|
+
* flood the dedup exists to close.
|
|
387
|
+
*/
|
|
388
|
+
export function failureCauseKey(err: unknown, degradation?: TurnDegradation): string {
|
|
389
|
+
const normalized = safeErrorMessage(err)
|
|
390
|
+
.replace(/"(?:turnId|threadId|conversationId|requestId)"\s*:\s*"[^"]*"/g, "")
|
|
391
|
+
// STOPS at a JSON delimiter (review codex P2a). `\S+` here crossed the
|
|
392
|
+
// closing quote and ate everything up to the next SPACE — measured: two
|
|
393
|
+
// errors differing only by httpStatusCode 503 vs 429 collapsed onto
|
|
394
|
+
// `Codex turn failed: {"message":"Request failed,` because the status sat
|
|
395
|
+
// AFTER the cf-ray. A normalisation that eats the discriminator gags the
|
|
396
|
+
// second outage: the exact defect this key exists to avoid.
|
|
397
|
+
.replace(/cf-ray:\s*[^\s",}\]]+/gi, "")
|
|
398
|
+
.replace(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/g, "")
|
|
399
|
+
// The RESET INSTANT of a quota, in the two shapes our own log carries for
|
|
400
|
+
// the same outage (review codex, .raw l.95 vs l.111): with the date and
|
|
401
|
+
// without. Stripping only the clock left them on different keys, so the
|
|
402
|
+
// same cause announced twice.
|
|
403
|
+
.replace(
|
|
404
|
+
/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2}(?:st|nd|rd|th)?,?\s*\d{4}\b/gi,
|
|
405
|
+
"",
|
|
406
|
+
)
|
|
407
|
+
.replace(/\b\d{1,2}:\d{2}(:\d{2})?\s*(AM|PM)?\b/gi, "")
|
|
408
|
+
.replace(/\b[0-9a-f]{12,}\b/gi, "")
|
|
409
|
+
.replace(/\s+/g, " ")
|
|
410
|
+
.trim();
|
|
411
|
+
const degraded = degradation && degradation.events > 0 ? "|degraded" : "";
|
|
412
|
+
// WHOLE, neither truncated nor hashed (review codex, both passes). Slicing to a
|
|
413
|
+
// display bound made the key lose identity past that bound — two errors sharing
|
|
414
|
+
// a long prefix became one cause and the second was silenced. A digest fixed
|
|
415
|
+
// that but kept a residual collision, which costs a SUPPRESSED notice: the
|
|
416
|
+
// defect this key exists to prevent, made rare instead of impossible.
|
|
417
|
+
// There is no size constraint to trade against: the key is one closure variable
|
|
418
|
+
// holding one cause at a time. Keeping the normalised string entire removes the
|
|
419
|
+
// class rather than shrinking it — a display concern must not decide identity,
|
|
420
|
+
// which is the whole lesson of P2b.
|
|
421
|
+
return `${normalized}${degraded}`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* #606 step-02 — read the transport degradation a runtime attached to the error
|
|
426
|
+
* it threw. The failing turn never returns a TurnResult, so the count cannot ride
|
|
427
|
+
* on one; the codex bridge stamps it on the Error itself. Any runtime that stamps
|
|
428
|
+
* nothing (gemma: a single fetch, no intermediate transport events to observe)
|
|
429
|
+
* simply yields undefined, and the notice omits the clause.
|
|
430
|
+
*/
|
|
431
|
+
export function readTurnDegradation(err: unknown): TurnDegradation | undefined {
|
|
432
|
+
const d = (err as { degradation?: unknown })?.degradation as TurnDegradation | undefined;
|
|
433
|
+
if (!d || typeof d.events !== "number" || d.events <= 0) return undefined;
|
|
434
|
+
return { events: d.events, lastStatus: typeof d.lastStatus === "number" ? d.lastStatus : undefined };
|
|
435
|
+
}
|
|
436
|
+
|
|
348
437
|
/**
|
|
349
438
|
* #429 cross-run dedup, mirror of dm_stream's `forwardMessage` guard (L146-147).
|
|
350
439
|
* True when this id was already DELIVERED in a prior run and must be skipped on a
|
|
@@ -570,6 +659,34 @@ export async function sseLoop(opts: {
|
|
|
570
659
|
const sseUrl = buildSseUrl(minted.ws_url, minted.conversation_id);
|
|
571
660
|
const ownPostedIds = new Set<string>();
|
|
572
661
|
|
|
662
|
+
// #606 anti-flood state: the cause key of the LAST failure actually announced
|
|
663
|
+
// on the channel. A blown quota fails every subsequent turn, so announcing each
|
|
664
|
+
// one would replace a silence bug with a spam bug. Closure state, owned by this
|
|
665
|
+
// loop — no module singleton (.claude/rules/architecture.md §7).
|
|
666
|
+
// Cross-RUN note. This comment CLAIMED the key survives a re-join; it does not,
|
|
667
|
+
// and N.11 measures the opposite (review codex, prose-has-no-test: the code was
|
|
668
|
+
// right, the sentence was false). What actually happens: the watermark
|
|
669
|
+
// deliberately does NOT advance on a failed reply (at-least-once retry, see
|
|
670
|
+
// below), so a re-join replays the message and fails again — and since each
|
|
671
|
+
// sseLoop call owns a FRESH closure, that re-join announces once more. Same for
|
|
672
|
+
// a process restart. Deliberate: a bridge that comes back and hits the same wall
|
|
673
|
+
// is information. Within one join, the key is what stops the flood.
|
|
674
|
+
let lastAnnouncedCause: string | undefined;
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* #606 — a DELIVERED turn means the previous cause is over: rearm the notice.
|
|
678
|
+
*
|
|
679
|
+
* Lives HERE, on the turn outcome, and not at a caller. Review F2 measured why:
|
|
680
|
+
* written at the text caller only, the VOICE path never rearmed, so
|
|
681
|
+
* text-fails → voice-recovers → text-fails-again left the third failure GAGGED
|
|
682
|
+
* — the issue's own defect, reproduced by the issue's fix. Placing it on the
|
|
683
|
+
* outcome removes the class instead of patching the two paths that exist today.
|
|
684
|
+
* An ABORTED turn never gets here: teardown says nothing about runtime health.
|
|
685
|
+
*/
|
|
686
|
+
function rearmFailureNotice(): void {
|
|
687
|
+
lastAnnouncedCause = undefined;
|
|
688
|
+
}
|
|
689
|
+
|
|
573
690
|
// Run one turn for `content` via the injected runtime, then auto-post its
|
|
574
691
|
// reply UNLESS an outbound tool already posted this turn (anti-double-post).
|
|
575
692
|
// Returns true when the reply was dropped because the stream was aborted
|
|
@@ -598,12 +715,15 @@ export async function sseLoop(opts: {
|
|
|
598
715
|
return true;
|
|
599
716
|
case "suppress-outbound":
|
|
600
717
|
process.stderr.write(`${logPrefix} outbound tool posted this turn on ${runtime.id}; suppressing final auto-post\n`);
|
|
718
|
+
rearmFailureNotice();
|
|
601
719
|
return false;
|
|
602
720
|
case "skip-empty":
|
|
603
721
|
process.stderr.write(`${logPrefix} turn on ${runtime.id} produced no text and used no outbound tool; nothing to deliver\n`);
|
|
722
|
+
rearmFailureNotice();
|
|
604
723
|
return false;
|
|
605
724
|
case "auto-post":
|
|
606
|
-
await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix });
|
|
725
|
+
await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix, fetchImpl });
|
|
726
|
+
rearmFailureNotice();
|
|
607
727
|
return false;
|
|
608
728
|
}
|
|
609
729
|
}
|
|
@@ -652,6 +772,49 @@ export async function sseLoop(opts: {
|
|
|
652
772
|
.finally(() => ackInFlightIds.delete(idStr));
|
|
653
773
|
}
|
|
654
774
|
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* #606 — say a failed turn on the CHANNEL, best-effort.
|
|
778
|
+
*
|
|
779
|
+
* Deliberately unable to make things worse:
|
|
780
|
+
* - the transport may be the very thing that broke (the measured case was a
|
|
781
|
+
* 503 storm), so a failing notice is swallowed to stderr, NEVER retried and
|
|
782
|
+
* never rethrown — one failure must not become two;
|
|
783
|
+
* - it posts through postBridgeReply, so the notice id lands in ownPostedIds
|
|
784
|
+
* like any other reply and cannot feed itself back through handleInboundMessage;
|
|
785
|
+
* - the cause key is recorded only AFTER a post that actually succeeded, so a
|
|
786
|
+
* lost notice does not silence the next one.
|
|
787
|
+
*/
|
|
788
|
+
async function announceTurnFailure(err: unknown): Promise<void> {
|
|
789
|
+
const degradation = readTurnDegradation(err);
|
|
790
|
+
const key = failureCauseKey(err, degradation);
|
|
791
|
+
if (key === lastAnnouncedCause) {
|
|
792
|
+
process.stderr.write(`${logPrefix} failure notice suppressed (same cause as the previous one)\n`);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
try {
|
|
796
|
+
await postBridgeReply({
|
|
797
|
+
baseUrl: baseUrl(),
|
|
798
|
+
runtime,
|
|
799
|
+
text: failureNoticeText(err, degradation),
|
|
800
|
+
ownPostedIds,
|
|
801
|
+
signal,
|
|
802
|
+
logPrefix,
|
|
803
|
+
fetchImpl,
|
|
804
|
+
});
|
|
805
|
+
lastAnnouncedCause = key;
|
|
806
|
+
} catch (notifyErr) {
|
|
807
|
+
// Includes BridgeShutdownError from an expired token: swallowed here on
|
|
808
|
+
// purpose. CONSEQUENCE, named because it is not obvious (review F3): a
|
|
809
|
+
// token expiry first met BY THE NOTICE is discovered later, on the next
|
|
810
|
+
// turn, instead of shutting the bridge down now. Accepted — this path
|
|
811
|
+
// runs while ALREADY handling a failure; escalating a
|
|
812
|
+
// shutdown from the notice would turn "the agent could not answer" into
|
|
813
|
+
// "the agent died", which is a worse report than the one we came to give.
|
|
814
|
+
process.stderr.write(`${logPrefix} failure notice could not be posted: ${safeErrorMessage(notifyErr)}\n`);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
655
818
|
async function handleInboundMessage(msg: ConversationMessage): Promise<boolean> {
|
|
656
819
|
const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
|
|
657
820
|
// TRUTHY check (not `!== undefined`): idStr === "" must yield NaN, not
|
|
@@ -688,6 +851,7 @@ export async function sseLoop(opts: {
|
|
|
688
851
|
} catch (err) {
|
|
689
852
|
if (err instanceof BridgeShutdownError) throw err;
|
|
690
853
|
process.stderr.write(`${logPrefix} failed to handle message: ${safeErrorMessage(err)}\n`);
|
|
854
|
+
await announceTurnFailure(err);
|
|
691
855
|
}
|
|
692
856
|
return options.once === true;
|
|
693
857
|
}
|
|
@@ -810,6 +974,12 @@ export async function sseLoop(opts: {
|
|
|
810
974
|
} catch (err) {
|
|
811
975
|
if (err instanceof BridgeShutdownError) throw err;
|
|
812
976
|
process.stderr.write(`${logPrefix} failed to handle transcription: ${safeErrorMessage(err)}\n`);
|
|
977
|
+
// #606: a VOICE turn that fails was the second silent swallow in this
|
|
978
|
+
// same chain — found while building the tests, not by the grep on
|
|
979
|
+
// "failed to handle message", which only ever proved the uniqueness of
|
|
980
|
+
// the TEXT path. A user speaking to a dead runtime deserves the same
|
|
981
|
+
// notice as one typing to it.
|
|
982
|
+
await announceTurnFailure(err);
|
|
813
983
|
}
|
|
814
984
|
if (options.once) return;
|
|
815
985
|
}
|
package/lib/bridge_tool_host.ts
CHANGED
|
@@ -92,8 +92,19 @@ export const TOOL_DEFS = [
|
|
|
92
92
|
"List the OTHER agents (instances) you can DM. Returns each agent's id, " +
|
|
93
93
|
"label, runtime kind, and whether it is online. Use it to find an id before message_agent. " +
|
|
94
94
|
"Normally project-scoped; an ORCHESTRATOR instance (#307) sees all the owner's projects, " +
|
|
95
|
-
"each entry tagged with a `project` field."
|
|
96
|
-
|
|
95
|
+
"each entry tagged with a `project` field. " +
|
|
96
|
+
"FILTER instead of listing everything (#501): `online: true` for live agents only, " +
|
|
97
|
+
"`label_prefix` for one team (e.g. \"501-\"). Both are applied server-side. " +
|
|
98
|
+
"If the presence source is unreachable the `online` filter is DROPPED and the answer " +
|
|
99
|
+
"says so — an empty list would wrongly read as \"no agent is alive\".",
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: "object",
|
|
102
|
+
properties: {
|
|
103
|
+
online: { type: "boolean", description: "Only agents currently seen online." },
|
|
104
|
+
label_prefix: { type: "string", description: 'Only labels starting with this, e.g. "501-".' },
|
|
105
|
+
},
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
},
|
|
97
108
|
},
|
|
98
109
|
{
|
|
99
110
|
name: "message_agent",
|
|
@@ -146,7 +157,7 @@ export async function dispatchBridgeTool(
|
|
|
146
157
|
): Promise<AgentToolResult> {
|
|
147
158
|
let result: AgentToolResult;
|
|
148
159
|
if (name === "list_agents") {
|
|
149
|
-
result = await listAgents(opts.ctx);
|
|
160
|
+
result = await listAgents(opts.ctx, args);
|
|
150
161
|
} else if (name === "message_agent") {
|
|
151
162
|
result = await messageAgent(opts.ctx, args);
|
|
152
163
|
} else if (name === "send_message") {
|
package/lib/claude_tool_defs.ts
CHANGED
|
@@ -71,10 +71,24 @@ export const CLAUDE_TOOL_DEFS = [
|
|
|
71
71
|
"Use this to find the id of an agent (e.g. 'Codex Review') before calling message_agent. " +
|
|
72
72
|
"Normally scoped to this project; if the owner designated this instance an ORCHESTRATOR " +
|
|
73
73
|
"(#307), the list covers ALL the owner's projects and each entry carries a `project` field. " +
|
|
74
|
-
"You are never in the list."
|
|
74
|
+
"You are never in the list. " +
|
|
75
|
+
"FILTER instead of listing everything (#501): `online: true` returns only agents the server " +
|
|
76
|
+
'currently sees online, `label_prefix` only those whose label starts with it (e.g. "501-" ' +
|
|
77
|
+
"for one team). Both are applied server-side, so an unfiltered call in a loop is the " +
|
|
78
|
+
"expensive path. If the presence source is unreachable the `online` filter is DROPPED and " +
|
|
79
|
+
'the answer says so — an empty list would wrongly read as "no agent is alive".',
|
|
75
80
|
inputSchema: {
|
|
76
81
|
type: "object" as const,
|
|
77
|
-
properties: {
|
|
82
|
+
properties: {
|
|
83
|
+
online: {
|
|
84
|
+
type: "boolean",
|
|
85
|
+
description: "Only agents the server currently sees online.",
|
|
86
|
+
},
|
|
87
|
+
label_prefix: {
|
|
88
|
+
type: "string",
|
|
89
|
+
description: 'Only agents whose label starts with this prefix, e.g. "501-".',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
78
92
|
required: [],
|
|
79
93
|
additionalProperties: false,
|
|
80
94
|
},
|
package/lib/connect.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
writeFileSync,
|
|
20
20
|
} from "node:fs";
|
|
21
21
|
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
22
|
-
import {
|
|
22
|
+
import { connectAuthPath, isQaBackend } from "./auth.js";
|
|
23
23
|
import { clientFingerprint } from "./fingerprint.js";
|
|
24
24
|
import { cfAccessHeaders } from "./cfAccess.js";
|
|
25
25
|
|
|
@@ -50,6 +50,21 @@ interface TerminalResponse {
|
|
|
50
50
|
|
|
51
51
|
type PollResponse = ConsumedResponse | PendingResponse | TerminalResponse;
|
|
52
52
|
|
|
53
|
+
const DEFAULT_NEXT_STEPS =
|
|
54
|
+
`Next steps:\n` +
|
|
55
|
+
` 1. Register the channel MCP server (one-time per machine):\n` +
|
|
56
|
+
` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +
|
|
57
|
+
` 2. Restart Claude Code in this directory.\n\n`;
|
|
58
|
+
|
|
59
|
+
// #613: qa never runs the published channel — the default advice would be wrong
|
|
60
|
+
// at the exact moment of the qa gesture.
|
|
61
|
+
export const QA_NEXT_STEPS =
|
|
62
|
+
`Next steps (qa):\n` +
|
|
63
|
+
` 1. Run the channel FROM SOURCE in the Gateway worktree: register viber-qa-channel\n` +
|
|
64
|
+
` in its local .mcp.json (bun ./viber-channel/viber-channel.ts,\n` +
|
|
65
|
+
` VIBER_BASE_URL=https://viber-qa.dgypx.dev). NEVER bunx viber-channel@latest.\n` +
|
|
66
|
+
` 2. See plans/613-env-qa (mount procedure).\n\n`;
|
|
67
|
+
|
|
53
68
|
const POLL_INTERVAL_MS = 2000;
|
|
54
69
|
const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
|
|
55
70
|
|
|
@@ -77,6 +92,7 @@ function writeAuthJson(
|
|
|
77
92
|
cwd: string,
|
|
78
93
|
data: ConsumedResponse,
|
|
79
94
|
fingerprint: string,
|
|
95
|
+
authPath: string,
|
|
80
96
|
): void {
|
|
81
97
|
const viberDir = join(cwd, ".viber");
|
|
82
98
|
mkdirSync(viberDir, { recursive: true });
|
|
@@ -85,7 +101,7 @@ function writeAuthJson(
|
|
|
85
101
|
// credentials to the same file the channel will read (e.g. dev.auth.json),
|
|
86
102
|
// instead of silently writing auth.json while the channel reads elsewhere.
|
|
87
103
|
// readme.md + .gitignore stay in <cwd>/.viber regardless.
|
|
88
|
-
|
|
104
|
+
// #613: `authPath` comes from connectAuthPath (the claim decides for qa).
|
|
89
105
|
mkdirSync(dirname(authPath), { recursive: true });
|
|
90
106
|
|
|
91
107
|
const auth: Record<string, unknown> = {
|
|
@@ -159,6 +175,8 @@ export async function runConnect(
|
|
|
159
175
|
cwd: string = process.cwd(),
|
|
160
176
|
): Promise<void> {
|
|
161
177
|
const { baseUrl, claimId } = parseClaimUrl(claimUrl);
|
|
178
|
+
// #613: resolved BEFORE any network call, so a contradiction fails early.
|
|
179
|
+
const authPath = connectAuthPath(baseUrl, cwd);
|
|
162
180
|
|
|
163
181
|
const fingerprint = clientFingerprint(cwd);
|
|
164
182
|
process.stderr.write(
|
|
@@ -191,14 +209,11 @@ export async function runConnect(
|
|
|
191
209
|
}
|
|
192
210
|
const data = (await pollResp.json()) as PollResponse;
|
|
193
211
|
if (data.status === "consumed") {
|
|
194
|
-
writeAuthJson(cwd, data, fingerprint);
|
|
212
|
+
writeAuthJson(cwd, data, fingerprint, authPath);
|
|
195
213
|
process.stdout.write(
|
|
196
214
|
`\n✓ Connected as ${data.user_email} to project '${data.project_name}'.\n` +
|
|
197
|
-
` Wrote ${
|
|
198
|
-
|
|
199
|
-
` 1. Register the channel MCP server (one-time per machine):\n` +
|
|
200
|
-
` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +
|
|
201
|
-
` 2. Restart Claude Code in this directory.\n\n`,
|
|
215
|
+
` Wrote ${authPath}\n\n` +
|
|
216
|
+
(isQaBackend(baseUrl) ? QA_NEXT_STEPS : DEFAULT_NEXT_STEPS),
|
|
202
217
|
);
|
|
203
218
|
return;
|
|
204
219
|
}
|