viber-channel 0.8.16 → 0.8.17

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.
@@ -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(ctx: AgentToolsContext): Promise<AgentToolResult> {
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(ctx.baseUrl(), ctx.instanceToken());
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
  }
@@ -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
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
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") {
@@ -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/instance.ts CHANGED
@@ -193,6 +193,16 @@ export interface AcquiredInstance {
193
193
  * 3. `auth.json` `instance_token` — reused (durable identity, D4 → same id).
194
194
  * 4. register a NEW instance with the stable project_token, persisted back to
195
195
  * auth.json (self-healing migration — no forced manual reconnect).
196
+ *
197
+ * ⚠ #501 DEPENDS ON THE PRIORITY ORDER ABOVE. vibe-master's readiness gate proves
198
+ * an agent came up by watching for an instance id that did NOT exist before the
199
+ * launch. Only route 2 mints one: routes 1 and 3 REUSE an id, so a launch that
200
+ * took either would be reported as "never registered" while being perfectly
201
+ * healthy — a false red. vibe-master's spawns take route 2 because
202
+ * `vibe-master/lib/terminal.ts` scrubs `VIBER_INSTANCE_TOKEN`/`_ID` from the
203
+ * child environment; the guarantee lives there, and this comment exists so a
204
+ * change here is not made unaware of it. Locked by the route-precedence tests in
205
+ * `test/instance.test.ts`.
196
206
  */
197
207
  export async function acquireInstance(
198
208
  baseUrl: string,
package/lib/peers.ts CHANGED
@@ -44,12 +44,41 @@ export class PeersHttpError extends Error {
44
44
  * Throws PeersHttpError on a non-2xx response so the caller can surface a
45
45
  * clear tool error — or fall back from user scope on a 403.
46
46
  */
47
+ /** A listing plus whether the presence source could be read at all (#501). */
48
+ export interface PeersListing {
49
+ peers: PeerView[];
50
+ presenceStatus: "available" | "unavailable";
51
+ }
52
+
53
+ export interface PeerFilters {
54
+ /** Only agents the server currently sees online. Dropped, server-side, when
55
+ * presence is unavailable — an empty list would read as "nobody is alive". */
56
+ online?: boolean;
57
+ /** Team/label prefix, e.g. "501-". */
58
+ labelPrefix?: string;
59
+ }
60
+
47
61
  export async function listPeers(
48
62
  baseUrl: string,
49
63
  instanceToken: string,
50
64
  scope?: "user",
51
- ): Promise<PeerView[]> {
52
- const qs = scope === "user" ? "?scope=user" : "";
65
+ filters: PeerFilters = {},
66
+ ): Promise<PeersListing> {
67
+ const params = new URLSearchParams();
68
+ if (scope === "user") params.set("scope", "user");
69
+ // #501 step-05: filtering happens SERVER-side. Doing it here would still pay
70
+ // the transfer that made this tool expensive to call in a loop.
71
+ if (filters.online === true) params.set("online", "true");
72
+ if (filters.labelPrefix !== undefined && filters.labelPrefix !== "") {
73
+ params.set("label_prefix", filters.labelPrefix);
74
+ }
75
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
76
+ // ⚠ NEVER add `X-Client-Fingerprint` to these headers (#501). The server picks
77
+ // its auth path from that header's PRESENCE: with it, `/peers` validates the
78
+ // Bearer as a project_token instead of an instance_token — and there is no
79
+ // fallback between the two, by design. Adding it here (directly, via a fetch
80
+ // wrapper, or via a proxy) would 401 `list_agents` for the whole fleet, with a
81
+ // cause invisible from this file.
53
82
  const resp = await fetch(`${baseUrl}/api/instances/peers${qs}`, {
54
83
  method: "GET",
55
84
  headers: { Authorization: `Bearer ${instanceToken}`, ...cfAccessHeaders() },
@@ -64,8 +93,27 @@ export async function listPeers(
64
93
  }
65
94
  throw new PeersHttpError(`listPeers failed: ${detail}`, resp.status);
66
95
  }
67
- const body = (await resp.json()) as { instances?: PeerView[] };
68
- return Array.isArray(body.instances) ? body.instances : [];
96
+ const body = (await resp.json()) as {
97
+ instances?: PeerView[];
98
+ presence_status?: string;
99
+ };
100
+ // A body we cannot read is a READ FAILURE, not an empty project (#501). The
101
+ // old `?? []` turned `{"presence_status":"available"}` into "No other agents
102
+ // are registered" — the exact false conclusion this work exists to remove.
103
+ // The same rule already guards the readiness client and the server's presence
104
+ // snapshot; it applies here too.
105
+ if (!Array.isArray(body.instances)) {
106
+ throw new PeersHttpError(
107
+ `listPeers: unreadable body (no \`instances\` array) — refusing to report an empty project`,
108
+ resp.status,
109
+ );
110
+ }
111
+ return {
112
+ peers: body.instances,
113
+ // Only the literal "available" is trusted: an older backend that does not
114
+ // send the field must fall on the cautious side (#501).
115
+ presenceStatus: body.presence_status === "available" ? "available" : "unavailable",
116
+ };
69
117
  }
70
118
 
71
119
  /**
@@ -78,12 +126,19 @@ export async function listPeers(
78
126
  export async function listPeersAuto(
79
127
  baseUrl: string,
80
128
  instanceToken: string,
81
- ): Promise<{ peers: PeerView[]; scope: "user" | "project" }> {
129
+ filters: PeerFilters = {},
130
+ ): Promise<PeersListing & { scope: "user" | "project" }> {
82
131
  try {
83
- return { peers: await listPeers(baseUrl, instanceToken, "user"), scope: "user" };
132
+ return {
133
+ ...(await listPeers(baseUrl, instanceToken, "user", filters)),
134
+ scope: "user",
135
+ };
84
136
  } catch (err) {
85
137
  if (err instanceof PeersHttpError && err.status === 403) {
86
- return { peers: await listPeers(baseUrl, instanceToken), scope: "project" };
138
+ return {
139
+ ...(await listPeers(baseUrl, instanceToken, undefined, filters)),
140
+ scope: "project",
141
+ };
87
142
  }
88
143
  throw err;
89
144
  }
@@ -25,6 +25,9 @@ export const SPAWN_REASONS = [
25
25
  "roster_invalid",
26
26
  "codex_unavailable",
27
27
  "launch_failed",
28
+ "not_ready",
29
+ "readiness_unverified",
30
+ "launch_refused",
28
31
  // Emitted by the runner itself
29
32
  "policy_invalid",
30
33
  "policy_refused",
@@ -224,6 +227,26 @@ export function messageForReason(
224
227
  return "codex is not launchable on this machine, so no agent was started — install codex or set VIBER_CODEX_BIN";
225
228
  case "launch_failed":
226
229
  return `a member failed to start${names}; the rest of the roster was skipped — the team is incomplete`;
230
+ // #501 — these two used to borrow `launch_failed`, whose sentence says the
231
+ // agent never started AND that the roster was skipped. For an agent that is
232
+ // running, and a roster that Q1bis deliberately did NOT interrupt, that
233
+ // sentence is false twice — and an automated caller retrying on it would
234
+ // create the duplicate labels we just built a detector for.
235
+ case "not_ready":
236
+ // Deliberately NOT "not registered": this family also covers an agent that
237
+ // registered and never beat, one whose process died after registering, and
238
+ // one whose instance vanished. Naming only the first would deny a
239
+ // registration the server can prove (Codex review). The precise reason
240
+ // stays in the structured human recap.
241
+ return `these agents STARTED but did not become ready on the channel${names} — the rest of the roster was NOT interrupted; close their window (terminal agents are not stopped automatically) and spawn them again`;
242
+ case "launch_refused":
243
+ // Says neither "started" nor "skipped": both would be false here.
244
+ return `no process was started for these members${names} — another launch of the same label was already in flight on this machine, so the spawn was refused locally; the rest of the roster was NOT interrupted. Re-spawn them with a distinct prefix`;
245
+ case "readiness_unverified":
246
+ // Also covers post-launch correlation ambiguity, not just an unreadable
247
+ // presence source — so the sentence states the CONSEQUENCE (we could not
248
+ // establish it) rather than one of its causes.
249
+ return `these agents started, but their availability could NOT be established${names} — this says nothing against them. Nothing was stopped; check them with list_agents before relying on them`;
227
250
  case "policy_invalid":
228
251
  return "this machine's local runner policy is present but invalid, so the spawn was refused";
229
252
  case "policy_refused":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.16",
3
+ "version": "0.8.17",
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": {
package/viber-channel.ts CHANGED
@@ -475,7 +475,7 @@ async function handleCallTool(
475
475
  const args = (request.params.arguments ?? {}) as Record<string, unknown>;
476
476
 
477
477
  if (request.params.name === "list_agents") {
478
- return libListAgents(channelToolsCtx);
478
+ return libListAgents(channelToolsCtx, args);
479
479
  }
480
480
 
481
481
  if (request.params.name === "message_agent") {