viber-channel 0.8.2 → 0.8.4

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/auth.ts CHANGED
@@ -33,21 +33,38 @@ export interface AuthJson {
33
33
  instance_id?: string;
34
34
  }
35
35
 
36
+ /**
37
+ * The environment signal — SINGLE source of truth for every env-dependent
38
+ * behavior in the channel (auth file, agent-session yield, …). The DEV backend
39
+ * is the only non-default: anything else (unset / staging / prod) uses the
40
+ * STAGING defaults, so a missing or ambiguous signal can never accidentally
41
+ * pull dev behavior into staging. Derived from `VIBER_BASE_URL`, which the
42
+ * launcher always sets per env.
43
+ */
44
+ export function isDevBackend(
45
+ baseUrl: string = process.env.VIBER_BASE_URL ?? "",
46
+ ): boolean {
47
+ return baseUrl.includes("viber-dev");
48
+ }
49
+
36
50
  /**
37
51
  * Resolve the auth file path.
38
52
  *
39
- * Defaults to `<cwd>/.viber/auth.json`. The optional `VIBER_AUTH_FILE` env var
40
- * overrides it — absolute paths are used verbatim, relative paths are resolved
41
- * against `cwd`. This lets a local launcher point the channel at an alternate
42
- * credential file (e.g. `.viber/dev.auth.json`) WITHOUT swapping `auth.json`,
43
- * so the default consumer flow (env var unset) is completely unaffected.
53
+ * The optional `VIBER_AUTH_FILE` env var overrides everything — absolute paths
54
+ * verbatim, relative paths resolved against `cwd`. With no override the file is
55
+ * DERIVED from the environment (single source of truth, {@link isDevBackend}):
56
+ * the dev backend uses `.viber/dev.auth.json`, everything else defaults to
57
+ * `.viber/auth.json` (staging). This kills the class of bugs where a dev channel
58
+ * silently authenticated against the dev backend with STAGING creds (`-32000`)
59
+ * because only `VIBER_BASE_URL` was set and the auth file defaulted to staging.
44
60
  */
45
61
  export function authFilePath(cwd: string = process.cwd()): string {
46
62
  const override = process.env.VIBER_AUTH_FILE;
47
63
  if (override !== undefined && override.trim() !== "") {
48
64
  return isAbsolute(override) ? override : join(cwd, override);
49
65
  }
50
- return join(cwd, ".viber", "auth.json");
66
+ const file = isDevBackend() ? "dev.auth.json" : "auth.json";
67
+ return join(cwd, ".viber", file);
51
68
  }
52
69
 
53
70
  export function loadAuth(cwd: string = process.cwd()): AuthJson {
@@ -27,7 +27,7 @@ import { buildSseUrl } from "./urls.js";
27
27
  import { cfAccessHeaders } from "./cfAccess.js";
28
28
  import { sessionFilePath, writeHandle } from "./channel_session.js";
29
29
  import type { AuthJson } from "./auth.js";
30
- import { acquireInstance, registerInstance, instanceKindFromEnv, type AcquiredInstance } from "./instance.js";
30
+ import { acquireInstance, registerInstance, instanceKindFromEnv, maybeAttachTeam, type AcquiredInstance } from "./instance.js";
31
31
  import {
32
32
  runPersistentControlStream,
33
33
  sendInstanceHeartbeat,
@@ -897,6 +897,9 @@ export async function acquireBridgeInstance(opts: {
897
897
  process.stderr.write(`${logPrefix} instance: registering fresh bridge instance\n`);
898
898
  const reg = await registerInstance(baseUrl, auth.project_id, auth.project_token, fingerprint, label, instanceKindFromEnv() ?? kind);
899
899
  process.stderr.write(`${logPrefix} instance: registered fresh bridge instance (id=${reg.instance_id})\n`);
900
+ // #398: best-effort self-attach to VIBER_INSTANCE_TEAM (codex/gemma path; the
901
+ // two branches above go through acquireInstance which already self-attaches).
902
+ await maybeAttachTeam(baseUrl, auth.project_id, auth.project_token, fingerprint, reg.instance_id, (m) => process.stderr.write(m));
900
903
  return { instance_token: reg.instance_token, instance_key: reg.instance_id };
901
904
  }
902
905
 
@@ -37,10 +37,14 @@ export type BridgeToolName = (typeof BRIDGE_TOOL_NAMES)[number];
37
37
  /** Outbound tools whose SUCCESS suppresses the bridge's final auto-post. */
38
38
  const OUTBOUND_TOOLS: ReadonlySet<string> = new Set(["send_message", "message_agent"]);
39
39
 
40
- const TOOL_DEFS = [
40
+ // Exported so tests can assert the send_message/message_agent framing on THIS
41
+ // (codex/gemma) surface independently of the Claude ListTools surface — a future
42
+ // edit must not fix one runtime while breaking the other (#390 codex review).
43
+ export const TOOL_DEFS = [
41
44
  {
42
45
  name: "send_message",
43
46
  description:
47
+ "Reply to the USER. This is the ONLY way to reach the user — they do NOT see your runtime/stdout, so never answer there. " +
44
48
  "Post your reply into the current Viber conversation. `text` is the conversational reply — " +
45
49
  "read aloud AND shown as Markdown, so keep it short and natural but use light Markdown (short " +
46
50
  "lists, numbered steps, **bold**) for readability; put actions/questions here, visibly. " +
@@ -77,7 +81,7 @@ const TOOL_DEFS = [
77
81
  {
78
82
  name: "message_agent",
79
83
  description:
80
- "Send a direct message to another agent. Pass the target agent's instance id " +
84
+ "Send a direct message to ANOTHER agent — NOT for replying to the user (use send_message for that). Pass the target agent's instance id " +
81
85
  "(from list_agents) and the text. Opens or reuses a private 1:1 DM and posts your message; " +
82
86
  "the agent's reply arrives back on this channel. Targets are normally same-project; an " +
83
87
  "ORCHESTRATOR instance (#307) can also message agents of the owner's other projects. " +
@@ -0,0 +1,43 @@
1
+ // The MCP `instructions` blob served to Claude sessions on the viber-channel
2
+ // server. Extracted into a PURE function so it can be asserted in tests without
3
+ // booting the MCP server (#390 step-03).
4
+ //
5
+ // #369 truncation fix: Claude Code truncates long MCP `instructions`
6
+ // ("…[truncated]") and this blob was ~3 KB → the TAIL was dropped before the
7
+ // agent saw it (that hid the agent's name #328, and threatened the #332 pointer
8
+ // + exit-intent). So: CRITICAL lines are FRONT-LOADED (identity → core reply →
9
+ // actions/questions → anti-deadlock → capabilities → exit) and the nice-to-have
10
+ // style notes live at the tail where a cut is harmless.
11
+
12
+ /**
13
+ * Build the MCP `instructions` string.
14
+ *
15
+ * @param identityLine the per-session agent identity line (from
16
+ * agentIdentityFromEnv()) — "" for JP's own session (no explicit-name flag),
17
+ * in which case it is filtered out and the rest of the blob still applies.
18
+ */
19
+ export function buildChannelInstructions(identityLine: string): string {
20
+ return [
21
+ identityLine,
22
+ // --- critical: front-loaded so truncation can never drop them ---
23
+ // #390: route the REPLY unambiguously. The <channel> header carries TWO
24
+ // source= attributes — the first is the MCP server name (always
25
+ // "viber-channel"), the LAST is meta.source, the real discriminant:
26
+ // "microphone" = voice, "conversation" = typed UI text (both = the USER),
27
+ // "agent-dm" = another agent. Naming the real values (not the server name)
28
+ // avoids the old imprecise "source=viber-channel" cue.
29
+ 'To reply to the USER, ALWAYS use send_message — NEVER write your answer to the terminal / your runtime stdout (the user does not watch it; there may be many terminals). A <channel> event whose metadata source (the last source= attribute) is "microphone" (voice) or "conversation" (typed text) is the user talking to you → reply with send_message. source="agent-dm" is ANOTHER agent → reply with message_agent. send_message = the user; message_agent = other agents ONLY, never the user.',
30
+ "Your send_message `text` is BOTH read aloud (TTS) AND shown as Markdown — make it natural aloud AND easy to read; lead with the answer, no preamble.",
31
+ "Put ACTIONS and QUESTIONS in `text`, visibly — never bury them in the artifact. Use light Markdown (short bullet/numbered lists, **bold**) to stay readable; a short spoken list is fine.",
32
+ // #343 anti-deadlock (hands-free: the user is NOT watching the terminal).
33
+ "CRITICAL — while this channel is active, NEVER block on a terminal prompt or AskUserQuestion: the user is hands-free and cannot see the terminal, so it deadlocks. Put EVERY question or choice in send_message `text` and take the answer from the next user message (voice or typed).",
34
+ // #332 discoverability pointer (the how-to lives in the capabilities tool).
35
+ "To orchestrate or spawn OTHER agents (Codex, Claude, Gemma) on this machine, call the `capabilities` tool for how — only when relevant.",
36
+ "On exit intent (bye, au revoir, stop) call stop_conversation(), speak a brief farewell, and stop.",
37
+ // --- nice-to-have style notes (safe near the tail) ---
38
+ "The `artifact` (format: markdown default, or code/json/html) is for HEAVY/LONG content (big code, large tables, long analyses, JSON, file lists); keep `text` a brief spoken summary that points to it ('details on the side'). Scripts/event handlers are stripped server-side.",
39
+ "NO EMOJI in `text` (read aloud — an emoji becomes spoken noise). Write identifiers LITERALLY (288, auth.json, viber-dev.dgypx.dev) — never spell out dots/dashes; a lone long token or path is better placed in the artifact.",
40
+ ]
41
+ .filter((line) => line.length > 0)
42
+ .join(" ");
43
+ }
package/lib/instance.ts CHANGED
@@ -76,6 +76,77 @@ export function instanceKindFromEnv(): string | undefined {
76
76
  return raw !== undefined && raw.trim() !== "" ? raw.trim() : undefined;
77
77
  }
78
78
 
79
+ /**
80
+ * The DB-backed team this process should join (#398), from `VIBER_INSTANCE_TEAM`
81
+ * (set by the spawn path — vibe-master's agent_spec). undefined when unset → the
82
+ * agent joins no team. Mirrors `instanceKindFromEnv`.
83
+ */
84
+ export function instanceTeamFromEnv(): string | undefined {
85
+ const raw = process.env.VIBER_INSTANCE_TEAM;
86
+ return raw !== undefined && raw.trim() !== "" ? raw.trim() : undefined;
87
+ }
88
+
89
+ /**
90
+ * POST /api/projects/:id/teams/members — attach this instance to a team by name
91
+ * (#398). A SEPARATE authorized call AFTER register (the register payload never
92
+ * carries a client-declared team — same discipline as the orchestrator flag).
93
+ * Team is project-scoped metadata; the worker upserts it and verifies the
94
+ * instance belongs to the token's project. Throws InstanceRegisterError on !ok.
95
+ */
96
+ export async function attachInstanceTeam(
97
+ baseUrl: string,
98
+ projectId: number,
99
+ projectToken: string,
100
+ fingerprint: string,
101
+ instanceId: string,
102
+ team: string,
103
+ ): Promise<void> {
104
+ const resp = await fetch(
105
+ `${baseUrl}/api/projects/${projectId}/teams/members`,
106
+ {
107
+ method: "POST",
108
+ headers: {
109
+ Authorization: `Bearer ${projectToken}`,
110
+ "X-Client-Fingerprint": fingerprint,
111
+ "Content-Type": "application/json",
112
+ ...cfAccessHeaders(),
113
+ },
114
+ body: JSON.stringify({ team, instance_id: instanceId }),
115
+ },
116
+ );
117
+ if (!resp.ok) {
118
+ const detail = await resp.text().catch(() => "");
119
+ throw new InstanceRegisterError(resp.status, detail);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Best-effort self-attach to `VIBER_INSTANCE_TEAM`, run AFTER a successful
125
+ * register/acquire (#398, R-CLAUDE: called from BOTH acquireInstance — claude/
126
+ * plain — and acquireBridgeInstance — codex/gemma). No-op when no team is
127
+ * requested or no server instance_id is known. A failure (legacy worker 404,
128
+ * validation, network) is logged and swallowed — the agent still works,
129
+ * un-teamed (forward-compatible). NOT retried.
130
+ */
131
+ export async function maybeAttachTeam(
132
+ baseUrl: string,
133
+ projectId: number,
134
+ projectToken: string,
135
+ fingerprint: string,
136
+ instanceId: string | undefined,
137
+ log: (msg: string) => void = (m) => process.stderr.write(m),
138
+ ): Promise<void> {
139
+ const team = instanceTeamFromEnv();
140
+ if (team === undefined || instanceId === undefined) return;
141
+ try {
142
+ await attachInstanceTeam(baseUrl, projectId, projectToken, fingerprint, instanceId, team);
143
+ log(`[viber-channel] team: attached instance ${instanceId.slice(0, 8)} to team "${team}"\n`);
144
+ } catch (err) {
145
+ const msg = err instanceof Error ? err.message : String(err);
146
+ log(`[viber-channel] team: self-attach to "${team}" failed (non-fatal): ${msg}\n`);
147
+ }
148
+ }
149
+
79
150
  export interface AcquiredInstance {
80
151
  instance_token: string;
81
152
  /**
@@ -116,6 +187,10 @@ export async function acquireInstance(
116
187
  log: (msg: string) => void = (m) => process.stderr.write(m),
117
188
  cwd: string = process.cwd(),
118
189
  ): Promise<AcquiredInstance> {
190
+ // Resolve the identity into `result` (one of the four paths), then run the
191
+ // #398 team self-attach ONCE at the single exit — covers every path (incl. the
192
+ // claude/plain register paths) without scattering the call (R-CLAUDE).
193
+ let result: AcquiredInstance;
119
194
  const envToken = process.env.VIBER_INSTANCE_TOKEN;
120
195
  if (envToken !== undefined && envToken.trim() !== "") {
121
196
  const token = envToken.trim();
@@ -123,42 +198,44 @@ export async function acquireInstance(
123
198
  const realId = envId !== undefined && envId.trim() !== "" ? envId.trim() : undefined;
124
199
  const key = realId ?? token;
125
200
  log(`[viber-channel] instance: using VIBER_INSTANCE_TOKEN (env-injected)\n`);
126
- return { instance_token: token, instance_key: key, instance_id: realId };
127
- }
128
-
129
- const envLabel = process.env.VIBER_CHANNEL_LABEL;
130
- if (envLabel !== undefined && envLabel.trim() !== "") {
131
- const label = envLabel.trim();
132
- log(`[viber-channel] instance: registering fresh labelled instance "${label}"\n`);
133
- const reg = await registerInstance(
134
- baseUrl,
135
- auth.project_id,
136
- auth.project_token,
137
- fingerprint,
138
- label,
139
- instanceKindFromEnv(),
140
- );
141
- return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
142
- }
143
-
144
- if (auth.instance_token !== undefined && auth.instance_token.trim() !== "") {
145
- const realId =
146
- auth.instance_id !== undefined && auth.instance_id.trim() !== "" ? auth.instance_id : undefined;
147
- const key = realId ?? auth.instance_token;
148
- log(`[viber-channel] instance: reusing persisted instance_token (id=${auth.instance_id ?? "?"})\n`);
149
- return { instance_token: auth.instance_token, instance_key: key, instance_id: realId };
201
+ result = { instance_token: token, instance_key: key, instance_id: realId };
202
+ } else {
203
+ const envLabel = process.env.VIBER_CHANNEL_LABEL;
204
+ if (envLabel !== undefined && envLabel.trim() !== "") {
205
+ const label = envLabel.trim();
206
+ log(`[viber-channel] instance: registering fresh labelled instance "${label}"\n`);
207
+ const reg = await registerInstance(
208
+ baseUrl,
209
+ auth.project_id,
210
+ auth.project_token,
211
+ fingerprint,
212
+ label,
213
+ instanceKindFromEnv(),
214
+ );
215
+ result = { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
216
+ } else if (auth.instance_token !== undefined && auth.instance_token.trim() !== "") {
217
+ const realId =
218
+ auth.instance_id !== undefined && auth.instance_id.trim() !== "" ? auth.instance_id : undefined;
219
+ const key = realId ?? auth.instance_token;
220
+ log(`[viber-channel] instance: reusing persisted instance_token (id=${auth.instance_id ?? "?"})\n`);
221
+ result = { instance_token: auth.instance_token, instance_key: key, instance_id: realId };
222
+ } else {
223
+ log(`[viber-channel] instance: none found — registering a new one via the project_token\n`);
224
+ const reg = await registerInstance(
225
+ baseUrl,
226
+ auth.project_id,
227
+ auth.project_token,
228
+ fingerprint,
229
+ undefined,
230
+ instanceKindFromEnv(),
231
+ );
232
+ persistInstanceCredentials(authFilePath(cwd), reg.instance_id, reg.instance_token, log);
233
+ log(`[viber-channel] instance: registered + persisted (id=${reg.instance_id})\n`);
234
+ result = { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
235
+ }
150
236
  }
151
237
 
152
- log(`[viber-channel] instance: none found — registering a new one via the project_token\n`);
153
- const reg = await registerInstance(
154
- baseUrl,
155
- auth.project_id,
156
- auth.project_token,
157
- fingerprint,
158
- undefined,
159
- instanceKindFromEnv(),
160
- );
161
- persistInstanceCredentials(authFilePath(cwd), reg.instance_id, reg.instance_token, log);
162
- log(`[viber-channel] instance: registered + persisted (id=${reg.instance_id})\n`);
163
- return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
238
+ // #398: best-effort self-attach to VIBER_INSTANCE_TEAM (no-op when unset).
239
+ await maybeAttachTeam(baseUrl, auth.project_id, auth.project_token, fingerprint, result.instance_id, log);
240
+ return result;
164
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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
@@ -42,7 +42,7 @@ import {
42
42
  } from "./lib/conversation.ts";
43
43
  import { startInstanceHeartbeat } from "./lib/heartbeat.ts";
44
44
  import { startParentWatchdog } from "./lib/parent_watchdog.ts";
45
- import { acquireInstance, registerInstance } from "./lib/instance.ts";
45
+ import { acquireInstance, maybeAttachTeam, registerInstance } from "./lib/instance.ts";
46
46
  import {
47
47
  runPersistentControlStream,
48
48
  sendInstanceHeartbeat,
@@ -55,6 +55,7 @@ import {
55
55
  } from "./lib/token_refresh.ts";
56
56
  import { awaitStableStartup } from "./lib/startup_gate.ts";
57
57
  import { agentIdentityFromEnv } from "./lib/bridge_core.ts";
58
+ import { buildChannelInstructions } from "./lib/channel_instructions.ts";
58
59
  import { warnIfStale } from "./lib/version_check.ts";
59
60
  import { capabilitiesText } from "./lib/capabilities.ts";
60
61
 
@@ -70,7 +71,17 @@ import { capabilitiesText } from "./lib/capabilities.ts";
70
71
  // loads in the same session with the same env; instead of racing it for the
71
72
  // channel lock, it bows out deterministically here. Normal sessions
72
73
  // (no VIBER_CHANNEL_AGENT_SESSION) are unaffected.
74
+ //
75
+ // BUT the yield is a STAGING/PROD-only behavior: the plugin channel only runs
76
+ // there (it is disabled in the Viber dev repo, #365). On the DEV backend there
77
+ // is NO plugin to yield to, so yielding = no channel at all (`-32000`, no
78
+ // registration). Derive this from the environment (single source of truth,
79
+ // isDevBackend) instead of relying on VIBER_CHANNEL_AGENT_SESSION not leaking
80
+ // from the parent shell into the spawned dev terminal (the real bug: that var
81
+ // leaked and terminal.ts never cleared it, so the dev channel silently bowed
82
+ // out). Default (non-dev) behavior is unchanged.
73
83
  if (
84
+ !isDevBackend() &&
74
85
  process.env.VIBER_CHANNEL_AGENT_SESSION === "1" &&
75
86
  process.env.VIBER_CHANNEL_PLUGIN !== "1"
76
87
  ) {
@@ -306,7 +317,12 @@ process.stdin.on("end", () => {
306
317
 
307
318
  // ---- Auth from .viber/auth.json ----
308
319
 
309
- import { authFilePath, loadAuth, persistInstanceCredentials } from "./lib/auth.ts";
320
+ import {
321
+ authFilePath,
322
+ isDevBackend,
323
+ loadAuth,
324
+ persistInstanceCredentials,
325
+ } from "./lib/auth.ts";
310
326
  import { handleConversationTokenExpired } from "./lib/channel_errors.ts";
311
327
  import { buildSseUrl } from "./lib/urls.ts";
312
328
 
@@ -353,32 +369,10 @@ const mcp = new Server(
353
369
  experimental: { "claude/channel": {} },
354
370
  tools: {},
355
371
  },
356
- // #369 truncation fix: Claude Code truncates long MCP `instructions`
357
- // ("…[truncated]") and this blob was ~3 KB → the TAIL was dropped before the
358
- // agent saw it (that hid the agent's name #328, and threatened the #332
359
- // pointer + exit-intent). So: CRITICAL lines are FRONT-LOADED (identity →
360
- // core reply → actions/questions → anti-deadlock → capabilities → exit) and
361
- // the nice-to-have style notes live at the tail where a cut is harmless.
362
- // Condensed vs the old prose (same substance — reviewed). Identity is "" for
363
- // JP's own session (no explicit-name flag) → the identity LINE is filtered
364
- // out for him; the rest of the (now shorter, reordered) blob applies to every
365
- // session including his.
366
- instructions: [
367
- agentIdentityFromEnv(),
368
- // --- critical: front-loaded so truncation can never drop them ---
369
- 'Voice transcripts arrive as <channel source="viber-channel"> events (the user\'s microphone speech). Reply with send_message. The `text` is BOTH read aloud (TTS) AND shown as Markdown — make it natural aloud AND easy to read; lead with the answer, no preamble.',
370
- "Put ACTIONS and QUESTIONS in `text`, visibly — never bury them in the artifact. Use light Markdown (short bullet/numbered lists, **bold**) to stay readable; a short spoken list is fine.",
371
- // #343 anti-deadlock (hands-free: the user is NOT watching the terminal).
372
- "CRITICAL — while this channel is active, NEVER block on a terminal prompt or AskUserQuestion: the user is hands-free and cannot see the terminal, so it deadlocks. Put EVERY question or choice in send_message `text` and take the answer from the next voice transcript.",
373
- // #332 discoverability pointer (the how-to lives in the capabilities tool).
374
- "To orchestrate or spawn OTHER agents (Codex, Claude, Gemma) on this machine, call the `capabilities` tool for how — only when relevant.",
375
- "On exit intent (bye, au revoir, stop) call stop_conversation(), speak a brief farewell, and stop.",
376
- // --- nice-to-have style notes (safe near the tail) ---
377
- "The `artifact` (format: markdown default, or code/json/html) is for HEAVY/LONG content (big code, large tables, long analyses, JSON, file lists); keep `text` a brief spoken summary that points to it ('details on the side'). Scripts/event handlers are stripped server-side.",
378
- "NO EMOJI in `text` (read aloud — an emoji becomes spoken noise). Write identifiers LITERALLY (288, auth.json, viber-dev.dgypx.dev) — never spell out dots/dashes; a lone long token or path is better placed in the artifact.",
379
- ]
380
- .filter((line) => line.length > 0)
381
- .join(" "),
372
+ // The blob lives in a PURE builder (lib/channel_instructions.ts) so it can be
373
+ // asserted in tests without booting the server (#390). Identity is "" for JP's
374
+ // own session (no explicit-name flag) → filtered out; the rest applies to all.
375
+ instructions: buildChannelInstructions(agentIdentityFromEnv()),
382
376
  }
383
377
  );
384
378
 
@@ -389,6 +383,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
389
383
  {
390
384
  name: "send_message",
391
385
  description:
386
+ "Reply to the USER. This is how you talk to the user — NOT the terminal / your stdout (they do not watch it). Calling this IS your reply for the turn. " +
392
387
  "Send a message into the Viber conversation this channel is bound to. " +
393
388
  "`text` is the conversational reply — it is BOTH read aloud (TTS) AND rendered as Markdown on screen, so keep it short and natural but use light Markdown (short bullet/numbered lists, **bold**) when it aids readability. Put any ACTIONS or QUESTIONS for the user here, visibly. " +
394
389
  "`artifact` is optional and carries HEAVY/LONG content (big code, large tables, long analyses, JSON, sanitized HTML) rendered in a side viewer; when used, keep `text` a brief summary that points to it. " +
@@ -444,7 +439,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
444
439
  {
445
440
  name: "message_agent",
446
441
  description:
447
- "Send a direct message to another agent (#288). " +
442
+ "Send a direct message to ANOTHER agent (#288) — NOT for replying to the user (use send_message for that). " +
448
443
  "Pass the target agent's instance id (from list_agents, or the from_instance_id of a DM you received) " +
449
444
  "and the message text. Opens or reuses a private 1:1 DM with that agent and posts your message; " +
450
445
  "the agent's reply arrives back on this channel tagged source=agent-dm. " +
@@ -698,6 +693,8 @@ try {
698
693
  persistInstanceCredentials(authFilePath(), reg.instance_id, reg.instance_token);
699
694
  instanceToken = reg.instance_token;
700
695
  OWN_INSTANCE_ID = reg.instance_id;
696
+ // #398: re-attach the fresh (post-revocation) instance to its team.
697
+ await maybeAttachTeam(BASE_URL, auth.project_id, auth.project_token, auth.client_fingerprint, reg.instance_id);
701
698
  SESSION_FILE = sessionFilePath(LOCK_BASE_URL, reg.instance_id, LOCK_DIR);
702
699
  minted = await acquireConversation(
703
700
  SESSION_FILE,
@@ -18,7 +18,7 @@ import {
18
18
  } from "./lib/conversation.ts";
19
19
  import { sessionFilePath, writeHandle } from "./lib/channel_session.ts";
20
20
  import { clientFingerprint } from "./lib/fingerprint.ts";
21
- import { acquireInstance, instanceKindFromEnv, registerInstance } from "./lib/instance.ts";
21
+ import { acquireInstance, instanceKindFromEnv, maybeAttachTeam, registerInstance } from "./lib/instance.ts";
22
22
  import {
23
23
  acquireBridgeLock as coreAcquireBridgeLock,
24
24
  agentIdentityFromEnv,
@@ -820,6 +820,9 @@ async function acquireBridgeInstance(
820
820
  process.stderr.write("[viber-codex-bridge] instance: registering fresh bridge instance\n");
821
821
  const reg = await registerInstance(baseUrl, auth.project_id, auth.project_token, fingerprint, label, instanceKindFromEnv() ?? "codex");
822
822
  process.stderr.write(`[viber-codex-bridge] instance: registered fresh bridge instance (id=${reg.instance_id})\n`);
823
+ // #398 (R-CODEX-FRESH): self-attach here too so codex is teamed structurally,
824
+ // not only via the VIBER_INSTANCE_TOKEN branch above (parity with claude).
825
+ await maybeAttachTeam(baseUrl, auth.project_id, auth.project_token, fingerprint, reg.instance_id);
823
826
  return { instance_token: reg.instance_token, instance_key: reg.instance_id };
824
827
  }
825
828
 
@@ -885,6 +888,8 @@ async function acquireTargetConversation(
885
888
  const reg = await registerInstance(BASE_URL, auth.project_id, auth.project_token, fingerprint, label, instanceKindFromEnv() ?? "codex");
886
889
  instanceToken = reg.instance_token;
887
890
  instanceKey = reg.instance_id;
891
+ // #398: re-attach the fresh (post-revocation) codex instance to its team.
892
+ await maybeAttachTeam(BASE_URL, auth.project_id, auth.project_token, fingerprint, reg.instance_id);
888
893
  // Identity changed → re-key the lock onto the new instance. Acquire the new
889
894
  // lock BEFORE releasing the old one: if acquisition throws, the old lock is
890
895
  // still held and the outer catch releases exactly it (no dangling reference).