viber-channel 0.8.0 → 0.8.2

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 { listPeers, openDm, type OpenDmResult } from "./peers.js";
20
+ import { listPeersAuto, openDm, type OpenDmResult } from "./peers.js";
21
21
  import { ConversationTokenExpiredError } from "./messages.js";
22
22
 
23
23
  /** MCP tool-result shape (text content + optional error flag). */
@@ -66,22 +66,36 @@ function errorText(s: string): AgentToolResult {
66
66
  }
67
67
 
68
68
  /**
69
- * list_agents — return this project's other agents so the model can pick one to
70
- * message. Guards the startup window: an empty instance token means "channel not
71
- * ready" rather than hitting the peers endpoint with no credential.
69
+ * list_agents — return the other agents so the model can pick one to message.
70
+ * Guards the startup window: an empty instance token means "channel not ready"
71
+ * rather than hitting the peers endpoint with no credential.
72
+ *
73
+ * #307: the listing runs at the widest allowed scope (listPeersAuto) — an
74
+ * ORCHESTRATOR instance sees the agents of ALL its owner's projects (each
75
+ * entry then carries a `project` field); a regular instance transparently
76
+ * falls back to its own project (unchanged behaviour, no `project` field).
72
77
  */
73
78
  export async function listAgents(ctx: AgentToolsContext): Promise<AgentToolResult> {
74
79
  if (!ctx.instanceToken()) {
75
80
  return errorText("Channel not ready: no instance identity yet.");
76
81
  }
77
82
  try {
78
- const peers = await listPeers(ctx.baseUrl(), ctx.instanceToken());
83
+ const { peers, scope } = await listPeersAuto(ctx.baseUrl(), ctx.instanceToken());
79
84
  // Project only what the model needs to pick a peer (drop last_seen /
80
85
  // active_conversation_id — available over the wire if a future tool needs them).
81
- const summary = peers.map((p) => ({ id: p.id, label: p.label, kind: p.kind, online: p.online }));
86
+ const summary = peers.map((p) => ({
87
+ id: p.id,
88
+ label: p.label,
89
+ kind: p.kind,
90
+ online: p.online,
91
+ // Present only in the user-scoped (orchestrator) listing.
92
+ ...(p.project_name !== undefined ? { project: p.project_name } : {}),
93
+ }));
82
94
  const body =
83
95
  summary.length === 0
84
- ? "No other agents are currently registered in this project."
96
+ ? scope === "user"
97
+ ? "No other agents are currently registered in any of your projects."
98
+ : "No other agents are currently registered in this project."
85
99
  : JSON.stringify(summary, null, 2);
86
100
  return text(body);
87
101
  } catch (err) {
@@ -68,16 +68,20 @@ const TOOL_DEFS = [
68
68
  {
69
69
  name: "list_agents",
70
70
  description:
71
- "List the OTHER agents (instances) in this project you can DM. Returns each agent's id, " +
72
- "label, runtime kind, and whether it is online. Use it to find an id before message_agent.",
71
+ "List the OTHER agents (instances) you can DM. Returns each agent's id, " +
72
+ "label, runtime kind, and whether it is online. Use it to find an id before message_agent. " +
73
+ "Normally project-scoped; an ORCHESTRATOR instance (#307) sees all the owner's projects, " +
74
+ "each entry tagged with a `project` field.",
73
75
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
74
76
  },
75
77
  {
76
78
  name: "message_agent",
77
79
  description:
78
- "Send a direct message to another agent in this project. Pass the target agent's instance id " +
80
+ "Send a direct message to another agent. Pass the target agent's instance id " +
79
81
  "(from list_agents) and the text. Opens or reuses a private 1:1 DM and posts your message; " +
80
- "the agent's reply arrives back on this channel. Fails if the target agent is offline.",
82
+ "the agent's reply arrives back on this channel. Targets are normally same-project; an " +
83
+ "ORCHESTRATOR instance (#307) can also message agents of the owner's other projects. " +
84
+ "Fails if the target agent is offline.",
81
85
  inputSchema: {
82
86
  type: "object",
83
87
  properties: {
@@ -26,15 +26,25 @@ export function capabilitiesText(): string {
26
26
  "",
27
27
  "Spawn one agent (CLI, non-interactive):",
28
28
  " vibe-master spawn --permission <read-only|read-write> \\",
29
- " [--runtime codex|claude|gemma] [--name <id>]",
29
+ " [--runtime codex|claude|gemma] [--name <id>] [--scope <name>]",
30
30
  " → only --permission is required; --runtime and --name are optional",
31
31
  " (--name auto-generated if omitted). Prints the agent id; it registers",
32
32
  " in Viber and comes online.",
33
33
  "",
34
+ "Multi-scope (#307): spawn into ANOTHER project by name. Register a project",
35
+ "folder once with `vibe-master scopes add <name> <path>`, then",
36
+ "`vibe-master spawn --scope <name> …` lands the agent in that project (its own",
37
+ ".viber auth). `--scope` and `--cwd` are exclusive. `vibe-master scopes` lists them.",
38
+ "",
34
39
  "Interactive: `vibe-master tui` (menu: + Add agent → runtime → permission → name).",
35
40
  "Inspect / stop: `vibe-master list` · `vibe-master kill <id>`.",
36
41
  "",
37
42
  "Talk to a spawned agent from here with list_agents + message_agent.",
43
+ "Cross-project (#307): by default list_agents/message_agent see only THIS",
44
+ "project. If the OWNER marks this instance an ORCHESTRATOR (toggle on the web",
45
+ "project page — server-authorized, never self-declared), list_agents then covers",
46
+ "ALL the owner's projects (each entry tagged with its project) and message_agent",
47
+ "can DM an agent in another of those projects.",
38
48
  "Requires vibe-master installed on the machine (see the Install page on the web).",
39
49
  "Run `vibe-master --help` for the full command reference.",
40
50
  ].join("\n");
@@ -297,6 +297,12 @@ export function runPersistentControlStream(
297
297
  };
298
298
 
299
299
  const done = (async () => {
300
+ // #389 step-02: announce ONCE that the instance control stream actually
301
+ // connected server-side (the SSE `connected` open-marker arrived) — proof
302
+ // the agent is ONLINE/reachable, not merely registered. vibe-master's spawn
303
+ // readiness gate polls the bridge log for this line, so a launch that
304
+ // registers then fails to connect (CF/401/network) is NOT reported as ready.
305
+ let announcedOnline = false;
300
306
  try {
301
307
  while (true) {
302
308
  if (signal?.aborted) {
@@ -305,6 +311,13 @@ export function runPersistentControlStream(
305
311
  }
306
312
  try {
307
313
  for await (const ev of subscribeControlStream(opts)) {
314
+ if (ev.event === "connected") {
315
+ if (!announcedOnline) {
316
+ announcedOnline = true;
317
+ log("[control-stream] online — instance control stream connected\n");
318
+ }
319
+ continue;
320
+ }
308
321
  if (ev.event === "stop") {
309
322
  settleFirstReject(new ControlStreamStopped());
310
323
  handlers.onStop?.("stopped");
@@ -314,7 +327,7 @@ export function runPersistentControlStream(
314
327
  handlers.onBeatNow?.();
315
328
  continue;
316
329
  }
317
- if (ev.event !== "join") continue; // connected / ping / unknown
330
+ if (ev.event !== "join") continue; // ping / unknown (connected handled above)
318
331
  const minted = parseJoinPayload(ev.data, log);
319
332
  if (!minted) continue;
320
333
  handlers.onJoin?.(minted);
package/lib/peers.ts CHANGED
@@ -11,7 +11,10 @@
11
11
 
12
12
  import { cfAccessHeaders } from "./cfAccess.js";
13
13
 
14
- /** A peer agent as returned by GET /api/instances/peers. */
14
+ /** A peer agent as returned by GET /api/instances/peers.
15
+ * project_id/project_name are present only in the user-scoped listing (#307):
16
+ * an orchestrator sees peers across all its owner's projects, each tagged
17
+ * with its project. */
15
18
  export interface PeerView {
16
19
  id: string;
17
20
  label: string | null;
@@ -19,17 +22,35 @@ export interface PeerView {
19
22
  online: boolean;
20
23
  last_seen: number | null;
21
24
  active_conversation_id: string | null;
25
+ project_id?: number;
26
+ project_name?: string;
27
+ }
28
+
29
+ /** Error carrying the HTTP status so callers can branch on 403 (#307). */
30
+ export class PeersHttpError extends Error {
31
+ constructor(
32
+ message: string,
33
+ public readonly status: number,
34
+ ) {
35
+ super(message);
36
+ this.name = "PeersHttpError";
37
+ }
22
38
  }
23
39
 
24
40
  /**
25
41
  * List the OTHER active instances of this agent's project (self excluded).
26
- * Throws on a non-2xx response so the caller can surface a clear tool error.
42
+ * With scope "user" (#307), an ORCHESTRATOR instance lists the peers of ALL
43
+ * its owner's projects (the server returns 403 for a non-orchestrator).
44
+ * Throws PeersHttpError on a non-2xx response so the caller can surface a
45
+ * clear tool error — or fall back from user scope on a 403.
27
46
  */
28
47
  export async function listPeers(
29
48
  baseUrl: string,
30
49
  instanceToken: string,
50
+ scope?: "user",
31
51
  ): Promise<PeerView[]> {
32
- const resp = await fetch(`${baseUrl}/api/instances/peers`, {
52
+ const qs = scope === "user" ? "?scope=user" : "";
53
+ const resp = await fetch(`${baseUrl}/api/instances/peers${qs}`, {
33
54
  method: "GET",
34
55
  headers: { Authorization: `Bearer ${instanceToken}`, ...cfAccessHeaders() },
35
56
  });
@@ -41,12 +62,33 @@ export async function listPeers(
41
62
  } catch {
42
63
  /* non-JSON body */
43
64
  }
44
- throw new Error(`listPeers failed: ${detail}`);
65
+ throw new PeersHttpError(`listPeers failed: ${detail}`, resp.status);
45
66
  }
46
67
  const body = (await resp.json()) as { instances?: PeerView[] };
47
68
  return Array.isArray(body.instances) ? body.instances : [];
48
69
  }
49
70
 
71
+ /**
72
+ * List peers at the WIDEST scope this instance is allowed (#307): try the
73
+ * user-scoped listing first; a 403 (non-orchestrator) transparently falls
74
+ * back to the project-scoped listing. Any other failure propagates. Single
75
+ * tool surface, zero client-side configuration: orchestrators see all their
76
+ * owner's projects, workers keep seeing their own project only.
77
+ */
78
+ export async function listPeersAuto(
79
+ baseUrl: string,
80
+ instanceToken: string,
81
+ ): Promise<{ peers: PeerView[]; scope: "user" | "project" }> {
82
+ try {
83
+ return { peers: await listPeers(baseUrl, instanceToken, "user"), scope: "user" };
84
+ } catch (err) {
85
+ if (err instanceof PeersHttpError && err.status === 403) {
86
+ return { peers: await listPeers(baseUrl, instanceToken), scope: "project" };
87
+ }
88
+ throw err;
89
+ }
90
+ }
91
+
50
92
  /** Result of opening (or reusing) a DM with a peer — carries the caller's own
51
93
  * membership conversation_token so it can post into the DM. */
52
94
  export interface OpenDmResult {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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
@@ -428,10 +428,12 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
428
428
  {
429
429
  name: "list_agents",
430
430
  description:
431
- "List the OTHER agents (instances) in this project you can talk to directly (#288). " +
431
+ "List the OTHER agents (instances) you can talk to directly (#288). " +
432
432
  "Returns each agent's id, label, runtime kind, and whether it is currently online. " +
433
433
  "Use this to find the id of an agent (e.g. 'Codex Review') before calling message_agent. " +
434
- "Only agents in the same project are returned; you are never in the list.",
434
+ "Normally scoped to this project; if the owner designated this instance an ORCHESTRATOR " +
435
+ "(#307), the list covers ALL the owner's projects and each entry carries a `project` field. " +
436
+ "You are never in the list.",
435
437
  inputSchema: {
436
438
  type: "object" as const,
437
439
  properties: {},
@@ -442,10 +444,12 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
442
444
  {
443
445
  name: "message_agent",
444
446
  description:
445
- "Send a direct message to another agent in this project (#288). " +
447
+ "Send a direct message to another agent (#288). " +
446
448
  "Pass the target agent's instance id (from list_agents, or the from_instance_id of a DM you received) " +
447
449
  "and the message text. Opens or reuses a private 1:1 DM with that agent and posts your message; " +
448
450
  "the agent's reply arrives back on this channel tagged source=agent-dm. " +
451
+ "Targets are normally same-project; an ORCHESTRATOR instance (#307) can also message agents " +
452
+ "of the owner's other projects (ids from its cross-project list_agents). " +
449
453
  "Fails if the target agent is offline.",
450
454
  inputSchema: {
451
455
  type: "object" as const,
@@ -356,7 +356,7 @@ function instructionsForTier(tier: AgentTier): string {
356
356
  return [
357
357
  "You are a bridge-owned Codex agent connected to Viber, in READ-WRITE mode.",
358
358
  "You MAY read files, create/modify files within the workspace, and run commands (including state-changing ones) to carry out the user's requests.",
359
- "The sandbox is workspace-write: writes are confined to the workspace and network access is blocked at the OS level.",
359
+ "The sandbox is workspace-write: writes are confined to the workspace, and network access is ENABLED — so you can run git (including `git push`) and open pull requests (e.g. with `gh`).",
360
360
  "There is NO human approval step, so be deliberate: make only the changes the user asked for, and avoid destructive commands unless explicitly requested.",
361
361
  VOICE_CONCISE_LINE,
362
362
  CHANNEL_TOOLS_LINE,
@@ -385,17 +385,43 @@ function instructionsForTier(tier: AgentTier): string {
385
385
  // Approval policy per tier. `read`/`write` use "never" so Codex runs its commands
386
386
  // WITHOUT a human approver (there is none on the bridge) — the SANDBOX is the real
387
387
  // guardrail (read-only blocks writes/network; workspace-write confines writes to
388
- // the workspace and still blocks network). `chat` keeps "on-request" (no exec anyway).
389
- function approvalForTier(tier: AgentTier): "never" | "on-request" {
388
+ // the workspace). `chat` keeps "on-request" (no exec anyway).
389
+ export function approvalForTier(tier: AgentTier): "never" | "on-request" {
390
390
  return tier === "chat" ? "on-request" : "never";
391
391
  }
392
392
 
393
393
  // Codex OS sandbox per tier. `write` widens to workspace-write (edits confined to
394
- // the workspace, network still blocked); chat/read stay read-only.
395
- function sandboxForTier(tier: AgentTier): "read-only" | "workspace-write" {
394
+ // the workspace); chat/read stay read-only.
395
+ export function sandboxForTier(tier: AgentTier): "read-only" | "workspace-write" {
396
396
  return tier === "write" ? "workspace-write" : "read-only";
397
397
  }
398
398
 
399
+ // Network access per tier (#389 step-03). ONLY the trusted `write` tier gets
400
+ // network — so a codex coder can `git push` and open its PR (git-over-https +
401
+ // `gh`). read/chat stay offline. SECURITY: codex's workspace-write sandbox opens
402
+ // ALL network, not git-only (the app-server sandbox has no per-domain allowlist),
403
+ // so this is a TRUST decision scoped to the write tier, which is already
404
+ // operator-authorized-at-launch for unattended command execution. If codex ever
405
+ // exposes an outbound-domain allowlist, tighten here (follow-up issue).
406
+ export function networkForTier(tier: AgentTier): boolean {
407
+ return tier === "write";
408
+ }
409
+
410
+ // The EXACT `sandboxPolicy` object sent on every `turn/start` (#389 step-03).
411
+ // Per the codex app-server protocol (generate-ts), `networkAccess` lives ONLY on
412
+ // this per-turn policy object (workspaceWrite/readOnly variants) — NOT on
413
+ // thread/start|resume, which take a bare `sandbox` MODE string. So the turn-level
414
+ // policy is the single lever for network. Exported to unit-test the payload/tier.
415
+ export function buildTurnSandboxPolicy(
416
+ tier: AgentTier,
417
+ ):
418
+ | { type: "workspaceWrite"; networkAccess: boolean }
419
+ | { type: "readOnly"; networkAccess: boolean } {
420
+ return sandboxForTier(tier) === "workspace-write"
421
+ ? { type: "workspaceWrite", networkAccess: networkForTier(tier) }
422
+ : { type: "readOnly", networkAccess: false };
423
+ }
424
+
399
425
  const AGENT_TIER: AgentTier = resolveAgentTier();
400
426
  // Identity line (#309 step-17) is appended AFTER the tier instructions so the
401
427
  // permission rules stay first + strongest; the role is framed as descriptive and
@@ -635,10 +661,7 @@ class CodexAppServer {
635
661
  cwd: process.cwd(),
636
662
  runtimeWorkspaceRoots: [process.cwd()],
637
663
  approvalPolicy: AGENT_APPROVAL_POLICY,
638
- sandboxPolicy:
639
- AGENT_SANDBOX === "workspace-write"
640
- ? { type: "workspaceWrite", networkAccess: false }
641
- : { type: "readOnly", networkAccess: false },
664
+ sandboxPolicy: buildTurnSandboxPolicy(AGENT_TIER),
642
665
  developerInstructions: AGENT_INSTRUCTIONS,
643
666
  responsesapiClientMetadata: { source: "viber-codex-bridge" },
644
667
  });
@@ -1115,7 +1138,7 @@ function setupCodexRuntime(deps: {
1115
1138
 
1116
1139
  function logCodexSafety(): void {
1117
1140
  process.stderr.write(
1118
- `${LOG_PREFIX} codex safety: tier=${AGENT_TIER}, sandbox=${AGENT_SANDBOX}, approvalPolicy=${AGENT_APPROVAL_POLICY}, network=false, ` +
1141
+ `${LOG_PREFIX} codex safety: tier=${AGENT_TIER}, sandbox=${AGENT_SANDBOX}, approvalPolicy=${AGENT_APPROVAL_POLICY}, network=${networkForTier(AGENT_TIER)}, ` +
1119
1142
  `instructions=${
1120
1143
  AGENT_TIER === "write"
1121
1144
  ? "read+write/run commands (workspace)"