@ctliz/agent-intercom-pi 0.11.0-connect.2 → 0.12.0-connect.3

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/README.md CHANGED
@@ -218,6 +218,139 @@ Sender identity in `intercom:control.from.id` comes from the broker delivery fra
218
218
 
219
219
  The control bus is intentionally not exposed as a model-facing generic Intercom tool. Companion extensions expose only their specific, reviewed actions.
220
220
 
221
+ ## Teams, names, and who can talk to whom
222
+
223
+ A **team** in Agent Intercom is the small group a session should treat as coworkers. It is not the same as the machine-wide session list. Agents that belong to a team should start with `intercom_team({})` and only fall back to `intercom_list({})` when they need an independently launched peer.
224
+
225
+ `/name` is how a human gives a session a stable, speakable handle. The broker still identifies every session by a stable session ID; the name is the convenient address other agents should use when it is unique.
226
+
227
+ ### Name a session with `/name`
228
+
229
+ `/name` is a Pi host command, not an Intercom command. After you set it, Intercom republishes the new name so other connected sessions can target it.
230
+
231
+ ```text
232
+ # Terminal 1
233
+ /name planner
234
+
235
+ # Terminal 2
236
+ /name worker
237
+
238
+ # Terminal 3
239
+ /name reviewer
240
+ ```
241
+
242
+ Good names are short, unique in the same scope, and describe the role: `planner`, `api-worker`, `frontend-dev`. Avoid spaces. If two live sessions share the same name, prefer the short ID shown by `intercom_list` or the exact full session ID from `/intercom-id`.
243
+
244
+ Check the published name:
245
+
246
+ ```typescript
247
+ intercom_status({})
248
+ intercom_list({})
249
+ ```
250
+
251
+ Copy a host-neutral contact snippet for another agent:
252
+
253
+ ```text
254
+ /intercom-id
255
+ ```
256
+
257
+ That snippet uses the unique name when possible and falls back to the stable session ID when names collide.
258
+
259
+ If you never set `/name`, Intercom still exposes a runtime-only fallback alias such as `subagent-chat-1a2b3c4d` so peers can reach the session. That alias is not stored as the Pi session title.
260
+
261
+ ### How `intercom_team` chooses a team
262
+
263
+ `intercom_team({})` has no arguments. It resolves the current group in this order and stops at the first match:
264
+
265
+ 1. **Orchestrator** — `~/.pi/agent/intercom/orchestrator/workers.json` has an owned record for this session (`AGENT_INTERCOM_WORKER_ID`) or this session is the current manager of live owned coworkers.
266
+ 2. **TmuxDeck manifest** — `AGENT_INTERCOM_TEAM_MANIFEST` points at a valid team file. The Lead is `leadId`; workers are the other members. An invalid or empty manifest fails closed and does not fall through to the live roster.
267
+ 3. **Same-scope live roster** — `AGENT_INTERCOM_SCOPE_ID` is set. Coworkers are the other live non-human sessions in that scope. `AGENT_INTERCOM_MANAGER_TARGET` / `AGENT_INTERCOM_MANAGER_SESSION_ID` name the Lead when present.
268
+ 4. **Standalone** — no manager and no coworkers. Use `/name` plus `intercom_list` / `intercom_send` instead.
269
+
270
+ Typical result:
271
+
272
+ ```text
273
+ Manager: planner [connected]
274
+ You: worker
275
+ Coworkers:
276
+ - reviewer target=reviewer (pi, worker, running) [connected]
277
+ ```
278
+
279
+ Use the returned `target` with `intercom_send` and `intercom_ask`. After Orchestrator adoption, the manager target updates without restarting the worker. `AGENT_INTERCOM_MANAGER_TARGET` is only a startup fallback.
280
+
281
+ Only an Orchestrator manager may inspect another session's pending inbox, and only with an exact connected coworker `target` from `intercom_team`:
282
+
283
+ ```typescript
284
+ intercom_team({})
285
+ intercom_pending({ session: "tmuxdeck-11111111-2222-4333-8444-555555555555" })
286
+ ```
287
+
288
+ Manifest, live-roster, and standalone teams cannot inspect another session's inbox.
289
+
290
+ ### Join a TmuxDeck circle without becoming a Team Worker
291
+
292
+ `/intercom-join` puts a standalone Pi session into an existing TmuxDeck workspace intercom circle. It is same-scope messaging only. It does **not** write a team manifest, does **not** enroll you as a Team Worker, and does **not** grant inbox inspection.
293
+
294
+ ```text
295
+ /intercom-join # list joinable workspaces
296
+ /intercom-join frontend # join by exact workspace name
297
+ /intercom-join 1 # join by the listed number
298
+ /intercom-join --scope <48hex> # join by the workspace scope
299
+ /intercom-status # confirm membership and visible peers
300
+ ```
301
+
302
+ Managed Team Workers and Orchestrator-owned sessions cannot join another circle. Listing never prints the raw scope.
303
+
304
+ ### Example: two-person billing team
305
+
306
+ Open two terminals in the same repo and start Pi in each.
307
+
308
+ ```text
309
+ # Terminal 1 — the person holding the plan
310
+ /name planner
311
+ intercom_status({})
312
+
313
+ # Terminal 2 — the person making the change
314
+ /name worker
315
+ intercom_list({})
316
+ ```
317
+
318
+ `intercom_list` should now show both `planner` and `worker`. If these terminals were launched inside the same TmuxDeck workspace, `intercom_team` also returns that pair as a live roster. Otherwise they are standalone peers that address each other by `/name`.
319
+
320
+ Planner assigns work:
321
+
322
+ ```typescript
323
+ intercom_send({
324
+ to: "worker",
325
+ message: "Add idempotent refund retries in src/billing/refunds.ts. Ask before changing the public error shape."
326
+ })
327
+ ```
328
+
329
+ Worker asks a blocking question, then reports done with `send`:
330
+
331
+ ```typescript
332
+ intercom_ask({
333
+ to: "planner",
334
+ message: "Refunds currently return 500 on a mid-flight timeout. Should I map that to 409 Conflict?"
335
+ })
336
+ // planner replies in the triggered turn:
337
+ intercom_reply({ message: "Yes. Keep the existing error body; only change the status code." })
338
+
339
+ intercom_send({
340
+ to: "planner",
341
+ message: "Done. Timeouts now return 409. Tests in refunds.test.ts pass."
342
+ })
343
+ ```
344
+
345
+ If a third Pi is already running as `reviewer` in the same TmuxDeck workspace, join from a new standalone window without becoming a Team Worker:
346
+
347
+ ```text
348
+ /intercom-join billing
349
+ /name reviewer
350
+ intercom_team({})
351
+ intercom_send({ to: "planner", message: "I can review the refund retry once worker is done." })
352
+ ```
353
+
221
354
  ## Workflow: Planner-Worker Coordination
222
355
 
223
356
  The most natural use of pi-intercom is splitting a task between two sessions — one holds the big picture, the other does the hands-on work. When the worker hits an ambiguity ("should I optimize for readability or performance here?"), they ask without losing context.
package/broker/spawn.ts CHANGED
@@ -173,6 +173,7 @@ export function getBrokerSpawnOptions(
173
173
  cwd: string;
174
174
  env: NodeJS.ProcessEnv;
175
175
  windowsHide: true;
176
+ shell: false;
176
177
  } {
177
178
  return {
178
179
  detached: true,
@@ -180,6 +181,7 @@ export function getBrokerSpawnOptions(
180
181
  cwd: extensionDir,
181
182
  env: { ...env, PI_CODING_AGENT_DIR: getAgentDirPath(env), NODE_NO_WARNINGS: "1" },
182
183
  windowsHide: true,
184
+ shell: false,
183
185
  };
184
186
  }
185
187
 
package/index.ts CHANGED
@@ -7,7 +7,7 @@ import { Text } from "@earendil-works/pi-tui";
7
7
  import { IntercomClient, type SendResult } from "./broker/client.ts";
8
8
  import { parseBossControl } from "./broker/boss-adapter.ts";
9
9
  import { spawnBrokerIfNeeded } from "./broker/spawn.ts";
10
- import { intercomScopeIdFromEnvForRegistration } from "./protocol-v4/contract.ts";
10
+ import { INTERCOM_SCOPE_ENV, intercomScopeIdFromEnvForRegistration } from "./protocol-v4/contract.ts";
11
11
  import { SessionListOverlay } from "./ui/session-list.ts";
12
12
  import { ComposeOverlay, type ComposeResult } from "./ui/compose.ts";
13
13
  import { InlineMessageComponent } from "./ui/inline-message.ts";
@@ -19,6 +19,19 @@ import { pendingAskId, ReplyTracker, type IntercomContext } from "./reply-tracke
19
19
  import { InboundMessageConflictError, PersistentInboundInbox, readPendingAsksSnapshot, type StoredInboundMessage } from "./inbound-inbox.ts";
20
20
  import { PersistentOutboundOutbox } from "./outbound-outbox.ts";
21
21
  import { formatIntercomTeam, resolveBossIntercomTeam, resolveIntercomTeam, resolveManagedInboxSession } from "./team.ts";
22
+ import {
23
+ classifyMembership,
24
+ currentTmuxWorkspace,
25
+ formatJoinStatus,
26
+ formatJoinSuccess,
27
+ formatJoinableWorkspaceList,
28
+ isZhLocale,
29
+ listScopedWorkspaces,
30
+ parseJoinArgs,
31
+ readSessionScope,
32
+ rejectManagedJoin,
33
+ workspaceNameForScope,
34
+ } from "./workspace-join.ts";
22
35
  import { authorizeBossSender, BossTeamScopeError, bossSelfSessionError, filterBossSessions, isBossControllerReadinessControl, readBossTeamScope, resolveBossLiveTarget } from "./boss-team-scope.ts";
23
36
  import {
24
37
  INTERCOM_CONTROL_DELIVERY_EVENT,
@@ -487,17 +500,50 @@ function parseSubagentIntercomPayload(payload: unknown): { to: string; message:
487
500
  const requestId = typeof record.requestId === "string" ? record.requestId : undefined;
488
501
  return { to: record.to, message: record.message, ...(requestId ? { requestId } : {}) };
489
502
  }
490
- function resolveIntercomPresenceName(sessionName: string | undefined, sessionId: string): string {
503
+
504
+ const GENERIC_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
505
+
506
+ export function resolveIntercomSessionId(
507
+ fallbackSessionId: string,
508
+ environment: NodeJS.ProcessEnv = process.env,
509
+ ): string {
510
+ const harnessSessionId = environment[INTERCOM_SESSION_ID_ENV]?.trim();
511
+ if (harnessSessionId) {
512
+ return harnessSessionId;
513
+ }
514
+ const genericSessionId = environment.AGENT_INTERCOM_SESSION_ID?.trim();
515
+ if (genericSessionId) {
516
+ if (!GENERIC_SESSION_ID_PATTERN.test(genericSessionId)) {
517
+ throw new Error("Invalid AGENT_INTERCOM_SESSION_ID: must match ^[A-Za-z0-9_-]{1,128}$");
518
+ }
519
+ return genericSessionId;
520
+ }
521
+ return fallbackSessionId;
522
+ }
523
+
524
+ export function resolveIntercomPresenceName(
525
+ sessionName: string | undefined,
526
+ sessionId: string,
527
+ environment: NodeJS.ProcessEnv = process.env,
528
+ ): string {
491
529
  const trimmedName = sessionName?.trim();
492
530
  if (trimmedName) {
493
531
  return trimmedName;
494
532
  }
533
+ const subagentName = environment[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
534
+ if (subagentName) {
535
+ return subagentName;
536
+ }
537
+ const genericName = environment.AGENT_INTERCOM_SESSION_NAME?.trim();
538
+ if (genericName) {
539
+ return genericName;
540
+ }
495
541
  const normalizedSessionId = sessionId.startsWith("session-") ? sessionId.slice("session-".length) : sessionId;
496
542
  return `${DEFAULT_UNNAMED_SESSION_ALIAS_PREFIX}-${normalizedSessionId.slice(0, 8)}`;
497
543
  }
498
- function buildPresenceIdentity(pi: ExtensionAPI, sessionId: string): { name: string } {
544
+ function buildPresenceIdentity(pi: ExtensionAPI, sessionId: string, environment: NodeJS.ProcessEnv = process.env): { name: string } {
499
545
  return {
500
- name: resolveIntercomPresenceName(pi.getSessionName(), sessionId),
546
+ name: resolveIntercomPresenceName(pi.getSessionName(), sessionId, environment),
501
547
  };
502
548
  }
503
549
  function formatSessionLabel(session: SessionInfo, duplicates: Set<string>, idPrefix: string): string {
@@ -558,11 +604,11 @@ interface ClipboardCopyResult {
558
604
  }
559
605
 
560
606
  function runDetachedClipboardCommand(command: string, args: string[], text: string): ClipboardCopyResult {
561
- const found = spawnSync("which", [command], { stdio: "ignore", timeout: 1000 });
607
+ const found = spawnSync("which", [command], { stdio: "ignore", timeout: 1000, shell: false });
562
608
  if (found.status !== 0) return { ok: false, error: `${command} not found` };
563
609
 
564
610
  try {
565
- const proc = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"] });
611
+ const proc = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"], shell: false });
566
612
  proc.stdin.on("error", () => {
567
613
  // Ignore EPIPE if the clipboard helper exits early.
568
614
  });
@@ -583,6 +629,7 @@ function runClipboardCommand(command: string, args: string[], text: string): Cli
583
629
  encoding: "utf8",
584
630
  timeout: 2000,
585
631
  stdio: ["pipe", "ignore", "pipe"],
632
+ shell: false,
586
633
  });
587
634
  if (result.status === 0) return { ok: true, method: command };
588
635
  if (result.error) return { ok: false, error: result.error.message };
@@ -622,7 +669,15 @@ function getNamePollMs(): number {
622
669
  return 1000;
623
670
  }
624
671
  export default function piIntercomExtension(pi: ExtensionAPI) {
625
- const runtimeScopeId = intercomScopeIdFromEnvForRegistration();
672
+ let runtimeScopeId = intercomScopeIdFromEnvForRegistration();
673
+ const initialHarnessSessionId = process.env[INTERCOM_SESSION_ID_ENV]?.trim();
674
+ const initialGenericSessionId = process.env.AGENT_INTERCOM_SESSION_ID?.trim();
675
+ if (initialGenericSessionId && !initialHarnessSessionId) {
676
+ if (!GENERIC_SESSION_ID_PATTERN.test(initialGenericSessionId)) {
677
+ throw new Error("Invalid AGENT_INTERCOM_SESSION_ID: must match ^[A-Za-z0-9_-]{1,128}$");
678
+ }
679
+ }
680
+ const configuredSessionId = initialHarnessSessionId || initialGenericSessionId;
626
681
  let client: IntercomClient | null = null;
627
682
  const config: IntercomConfig = loadConfig();
628
683
  const bossTeamScope = readBossTeamScope();
@@ -739,8 +794,11 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
739
794
  return null;
740
795
  }
741
796
  try {
742
- if (currentSessionId && ctx.sessionManager.getSessionId() !== currentSessionId) {
743
- return null;
797
+ if (currentSessionId) {
798
+ const expectedSessionId = configuredSessionId || ctx.sessionManager.getSessionId();
799
+ if (expectedSessionId !== currentSessionId) {
800
+ return null;
801
+ }
744
802
  }
745
803
  void ctx.hasUI;
746
804
  return ctx;
@@ -789,11 +847,12 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
789
847
  ...(runtimeInstanceId ? { runtimeInstanceId } : {}),
790
848
  };
791
849
  }
792
- function syncPresenceIdentity(sessionId: string): void {
850
+ function syncPresenceIdentity(sessionId?: string): void {
793
851
  if (!client || !getLiveContext()) {
794
852
  return;
795
853
  }
796
- const identity = buildPresenceIdentity(pi, sessionId);
854
+ const resolvedId = currentSessionId ?? (configuredSessionId || (sessionId ? sessionId : "default"));
855
+ const identity = buildPresenceIdentity(pi, resolvedId);
797
856
  lastPresenceName = identity.name;
798
857
  client.updatePresence({ ...identity, status: currentStatus() });
799
858
  }
@@ -814,6 +873,20 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
814
873
  function publishIntercomSessionId(sessionId: string): void {
815
874
  process.env[INTERCOM_SESSION_ID_ENV] = sessionId;
816
875
  }
876
+ async function switchRuntimeScope(nextScopeId: string): Promise<void> {
877
+ if (runtimeScopeId === nextScopeId && process.env[INTERCOM_SCOPE_ENV] === nextScopeId) {
878
+ return;
879
+ }
880
+ runtimeScopeId = nextScopeId;
881
+ process.env[INTERCOM_SCOPE_ENV] = nextScopeId;
882
+ const previousClient = client;
883
+ client = null;
884
+ if (previousClient) {
885
+ await previousClient.disconnect(true).catch(() => undefined);
886
+ }
887
+ await ensureConnected("tool");
888
+ }
889
+
817
890
  function restoreIntercomSessionId(): void {
818
891
  if (previousIntercomSessionId === undefined) {
819
892
  delete process.env[INTERCOM_SESSION_ID_ENV];
@@ -1343,7 +1416,8 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1343
1416
  rejectAllReplyWaiters(new Error("Session replaced"));
1344
1417
  replyTracker.reset();
1345
1418
  runtimeContext = ctx;
1346
- currentSessionId = ctx.sessionManager.getSessionId();
1419
+ currentSessionId = configuredSessionId || ctx.sessionManager.getSessionId();
1420
+ publishIntercomSessionId(currentSessionId);
1347
1421
  if (bossTeamScope.present && bossSelfSessionError(bossTeamScope, currentSessionId)) {
1348
1422
  const staleOutbox = new PersistentOutboundOutbox(currentSessionId);
1349
1423
  const removed = staleOutbox.list().length;
@@ -1666,7 +1740,8 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1666
1740
  scheduleInboundFlush(0);
1667
1741
  });
1668
1742
  pi.on("turn_start", (_event, ctx) => {
1669
- const sessionId = ctx.sessionManager.getSessionId();
1743
+ const rawSessionId = ctx.sessionManager.getSessionId();
1744
+ const sessionId = configuredSessionId || rawSessionId;
1670
1745
  if (!currentSessionId || sessionId !== currentSessionId) {
1671
1746
  if (!config.enabled) {
1672
1747
  return;
@@ -1688,7 +1763,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1688
1763
  currentModel = event.model.id;
1689
1764
  if (client) {
1690
1765
  client.updatePresence({
1691
- ...buildPresenceIdentity(pi, ctx.sessionManager.getSessionId()),
1766
+ ...buildPresenceIdentity(pi, currentSessionId ?? (configuredSessionId || ctx.sessionManager.getSessionId())),
1692
1767
  model: event.model.id,
1693
1768
  status: currentStatus(),
1694
1769
  });
@@ -2814,6 +2889,107 @@ Usage:
2814
2889
  },
2815
2890
  });
2816
2891
 
2892
+ pi.registerCommand("intercom-join", {
2893
+ description: "Join an existing TmuxDeck workspace intercom circle. This does not enroll you as a Team Worker.",
2894
+ handler: async (args, ctx) => {
2895
+ const zh = isZhLocale();
2896
+ const fail = (workspace?: string) => {
2897
+ notifyIfLive(ctx, workspace
2898
+ ? (zh
2899
+ ? `无法加入工作区 ${workspace} 的通话圈。`
2900
+ : `Could not join the intercom circle for workspace ${workspace}.`)
2901
+ : (zh ? "无法加入该工作区通话圈。" : "Could not join that workspace intercom circle."), "error");
2902
+ };
2903
+ try {
2904
+ const blocked = rejectManagedJoin(classifyMembership(), zh);
2905
+ if (blocked) {
2906
+ notifyIfLive(ctx, blocked, "error");
2907
+ return;
2908
+ }
2909
+ const parsed = parseJoinArgs(args);
2910
+ const workspaces = await listScopedWorkspaces();
2911
+ let workspace: string | undefined;
2912
+ if (parsed.kind === "list") {
2913
+ notifyIfLive(ctx, formatJoinableWorkspaceList({ workspaces, zh }), "info");
2914
+ return;
2915
+ }
2916
+ if (parsed.kind === "index" && parsed.index) {
2917
+ const selected = workspaces[parsed.index - 1];
2918
+ if (!selected) {
2919
+ notifyIfLive(ctx, zh
2920
+ ? "没有这个编号的工作区。"
2921
+ : "No workspace uses that number.", "error");
2922
+ return;
2923
+ }
2924
+ workspace = selected.sessionName;
2925
+ } else if (parsed.kind === "workspace" && parsed.workspace) {
2926
+ workspace = parsed.workspace;
2927
+ } else if (parsed.kind === "scope" && parsed.scope) {
2928
+ workspace = await workspaceNameForScope(parsed.scope);
2929
+ if (!workspace) {
2930
+ fail();
2931
+ return;
2932
+ }
2933
+ }
2934
+ if (!workspace) {
2935
+ fail();
2936
+ return;
2937
+ }
2938
+ const scope = await readSessionScope(workspace);
2939
+ if (!scope) {
2940
+ fail(workspace);
2941
+ return;
2942
+ }
2943
+ if (!getLiveContext(ctx)) {
2944
+ startSessionRuntime(ctx);
2945
+ }
2946
+ await switchRuntimeScope(scope);
2947
+ syncPresenceIdentity(currentSessionId ?? ctx.sessionManager.getSessionId());
2948
+ const displayName = currentSessionId
2949
+ ? buildPresenceIdentity(pi, currentSessionId).name
2950
+ : (pi.getSessionName()?.trim() || "unnamed");
2951
+ notifyIfLive(ctx, formatJoinSuccess({
2952
+ workspace,
2953
+ name: displayName,
2954
+ zh,
2955
+ }), "info");
2956
+ } catch (error) {
2957
+ notifyIfLive(ctx, getErrorMessage(error), "error");
2958
+ }
2959
+ },
2960
+ });
2961
+
2962
+ pi.registerCommand("intercom-status", {
2963
+ description: "Show whether this session is standalone, same-scope, tmuxdeck-team, or orchestrator.",
2964
+ handler: async (_args, ctx) => {
2965
+ const zh = isZhLocale();
2966
+ try {
2967
+ const membership = classifyMembership();
2968
+ const workspace = await currentTmuxWorkspace()
2969
+ ?? (runtimeScopeId ? await workspaceNameForScope(runtimeScopeId) : undefined);
2970
+ const displayName = currentSessionId
2971
+ ? buildPresenceIdentity(pi, currentSessionId).name
2972
+ : (pi.getSessionName()?.trim() || "unnamed");
2973
+ let peers: string[] = [];
2974
+ if (client?.isConnected()) {
2975
+ const sessions = await client.listSessions();
2976
+ peers = sessions
2977
+ .filter((session) => session.id !== client?.sessionId && session.model !== "human")
2978
+ .map((session) => session.name || session.id);
2979
+ }
2980
+ notifyIfLive(ctx, formatJoinStatus({
2981
+ membership,
2982
+ workspace,
2983
+ name: displayName,
2984
+ peers,
2985
+ zh,
2986
+ }), "info");
2987
+ } catch (error) {
2988
+ notifyIfLive(ctx, getErrorMessage(error), "error");
2989
+ }
2990
+ },
2991
+ });
2992
+
2817
2993
  pi.registerShortcut("alt+m", {
2818
2994
  description: "Open session intercom",
2819
2995
  handler: async (ctx) => openIntercomOverlay(ctx),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctliz/agent-intercom-pi",
3
- "version": "0.11.0-connect.2",
3
+ "version": "0.12.0-connect.3",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -44,7 +44,7 @@
44
44
  ]
45
45
  },
46
46
  "dependencies": {
47
- "@ctliz/agent-intercom-core": "git+https://github.com/ctliz/agent-intercom-core.git#37e074970e2a9de32a16fc325607c3b476b0bd45",
47
+ "@ctliz/agent-intercom-core": "0.2.0",
48
48
  "@earendil-works/pi-ai": "*",
49
49
  "@earendil-works/pi-coding-agent": "*",
50
50
  "@earendil-works/pi-tui": "*",
@@ -397,6 +397,15 @@ Use `/name` so others can target you easily:
397
397
  /name planner
398
398
  ```
399
399
 
400
+ To join an existing TmuxDeck workspace intercom circle without becoming a Team Worker:
401
+
402
+ ```
403
+ /intercom-join frontend
404
+ /intercom-status
405
+ ```
406
+
407
+ `/intercom-join` with no argument lists available TmuxDeck workspaces. Joining is same-scope messaging only; it does not enroll you as a Team Worker.
408
+
400
409
  ## Error Handling
401
410
 
402
411
  ### Common Errors and Solutions
package/team.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { readTeamManifest, TeamManifestError } from "@ctliz/agent-intercom-core/team-manifest";
3
4
  import { getAgentDirPath } from "./broker/paths.ts";
4
5
  import { bossSelfSessionError, resolveBossLiveSession, type BossTeamScope } from "./boss-team-scope.ts";
5
6
 
6
7
  export interface TeamSession {
7
8
  id: string;
8
9
  name?: string;
10
+ model?: string;
9
11
  origin?: "local" | "remote";
10
12
  }
11
13
 
@@ -29,9 +31,12 @@ export interface TeamMember {
29
31
  connected: boolean;
30
32
  }
31
33
 
34
+ export type IntercomTeamSource = "orchestrator" | "manifest" | "live_roster" | "standalone";
35
+
32
36
  export interface IntercomTeam {
37
+ source: IntercomTeamSource;
33
38
  teamId?: string;
34
- self: { id: string; workerId?: string; isManager: boolean };
39
+ self: { id: string; workerId?: string; isManager: boolean; role?: string };
35
40
  manager?: { target: string; connected: boolean };
36
41
  controller?: { target: string; connected: boolean };
37
42
  coworkers: TeamMember[];
@@ -46,6 +51,9 @@ export function resolveManagedInboxSession(input: {
46
51
  if (!input.team.self.isManager) {
47
52
  throw new Error("Only a manager may inspect another session's pending-ask inbox");
48
53
  }
54
+ if (input.team.source !== "orchestrator") {
55
+ throw new Error("Pending-ask inbox inspection is only available for orchestrator-managed teams");
56
+ }
49
57
  const member = input.team.coworkers.find((entry) => entry.target === input.requestedSession);
50
58
  if (!member) {
51
59
  throw new Error(`Pending-ask inbox access denied for "${input.requestedSession}"; select an owned coworker target returned by intercom_team`);
@@ -80,6 +88,13 @@ async function readWorkers(agentDir: string): Promise<StoredWorker[]> {
80
88
  }
81
89
  }
82
90
 
91
+ /**
92
+ * Resolves the Intercom team following the exact priority hierarchy:
93
+ * 1. Authoritative Orchestrator record in workers.json (matching AGENT_INTERCOM_WORKER_ID or active manager ownership)
94
+ * 2. Explicit TmuxDeck Manifest (AGENT_INTERCOM_TEAM_MANIFEST)
95
+ * 3. Workspace Live Same-Scope Non-Human Roster Fallback (AGENT_INTERCOM_SCOPE_ID present)
96
+ * 4. Standalone unmanaged session
97
+ */
83
98
  export async function resolveIntercomTeam(input: {
84
99
  selfId: string;
85
100
  sessions: TeamSession[];
@@ -93,39 +108,184 @@ export async function resolveIntercomTeam(input: {
93
108
  const current = workerId
94
109
  ? workers.find((worker) => stringValue(worker.id) === workerId && (!runId || stringValue(worker.runId) === runId))
95
110
  : undefined;
96
- const managerTarget = stringValue(current?.managerSessionId) ?? stringValue(env.AGENT_INTERCOM_MANAGER_TARGET) ?? stringValue(env.AGENT_INTERCOM_MANAGER_SESSION_ID);
97
- const teamId = managerTarget ?? input.selfId;
98
- const isManager = !managerTarget;
99
- const coworkers = workers
100
- .filter((worker) => worker.owned === true)
101
- .filter((worker) => stringValue(worker.managerSessionId) === teamId)
102
- .filter((worker) => LIVE_STATES.has(stringValue(worker.state) ?? ""))
103
- .filter((worker) => stringValue(worker.id) !== workerId)
104
- .map((worker): TeamMember | undefined => {
105
- const id = stringValue(worker.id);
106
- if (!id) return undefined;
107
- const target = stringValue(worker.intercomTarget) ?? id;
111
+
112
+ // 1. Authoritative Orchestrator Record
113
+ if (current) {
114
+ const managerTarget =
115
+ stringValue(current.managerSessionId) ??
116
+ stringValue(env.AGENT_INTERCOM_MANAGER_TARGET) ??
117
+ stringValue(env.AGENT_INTERCOM_MANAGER_SESSION_ID);
118
+
119
+ const coworkers: TeamMember[] = managerTarget
120
+ ? workers
121
+ .filter((worker) => worker.owned === true)
122
+ .filter((worker) => stringValue(worker.managerSessionId) === managerTarget)
123
+ .filter((worker) => LIVE_STATES.has(stringValue(worker.state) ?? ""))
124
+ .filter((worker) => stringValue(worker.id) !== workerId)
125
+ .map((worker): TeamMember | undefined => {
126
+ const id = stringValue(worker.id);
127
+ if (!id) return undefined;
128
+ const target = stringValue(worker.intercomTarget) ?? id;
129
+ return {
130
+ id,
131
+ target,
132
+ ...(stringValue(worker.harness) ? { harness: stringValue(worker.harness) } : {}),
133
+ ...(stringValue(worker.role) ? { role: stringValue(worker.role) } : {}),
134
+ ...(stringValue(worker.state) ? { state: stringValue(worker.state) } : {}),
135
+ connected: connectedTo(input.sessions, target),
136
+ };
137
+ })
138
+ .filter((member): member is TeamMember => Boolean(member))
139
+ : [];
140
+
141
+ return {
142
+ source: "orchestrator",
143
+ ...(managerTarget ? { teamId: managerTarget } : {}),
144
+ self: { id: input.selfId, ...(workerId ? { workerId } : {}), isManager: false },
145
+ ...(managerTarget
146
+ ? { manager: { target: managerTarget, connected: connectedTo(input.sessions, managerTarget) } }
147
+ : {}),
148
+ coworkers,
149
+ };
150
+ }
151
+
152
+ if (workerId === undefined && input.selfId) {
153
+ const managerOwnedCoworkers = workers
154
+ .filter((worker) => worker.owned === true)
155
+ .filter((worker) => stringValue(worker.managerSessionId) === input.selfId)
156
+ .filter((worker) => LIVE_STATES.has(stringValue(worker.state) ?? ""))
157
+ .map((worker): TeamMember | undefined => {
158
+ const id = stringValue(worker.id);
159
+ if (!id) return undefined;
160
+ const target = stringValue(worker.intercomTarget) ?? id;
161
+ return {
162
+ id,
163
+ target,
164
+ ...(stringValue(worker.harness) ? { harness: stringValue(worker.harness) } : {}),
165
+ ...(stringValue(worker.role) ? { role: stringValue(worker.role) } : {}),
166
+ ...(stringValue(worker.state) ? { state: stringValue(worker.state) } : {}),
167
+ connected: connectedTo(input.sessions, target),
168
+ };
169
+ })
170
+ .filter((member): member is TeamMember => Boolean(member));
171
+
172
+ if (managerOwnedCoworkers.length > 0) {
108
173
  return {
109
- id,
110
- target,
111
- ...(stringValue(worker.harness) ? { harness: stringValue(worker.harness) } : {}),
112
- ...(stringValue(worker.role) ? { role: stringValue(worker.role) } : {}),
113
- ...(stringValue(worker.state) ? { state: stringValue(worker.state) } : {}),
114
- connected: connectedTo(input.sessions, target),
174
+ source: "orchestrator",
175
+ teamId: input.selfId,
176
+ self: { id: input.selfId, isManager: true },
177
+ manager: { target: input.selfId, connected: true },
178
+ coworkers: managerOwnedCoworkers,
115
179
  };
116
- })
117
- .filter((member): member is TeamMember => Boolean(member));
180
+ }
181
+ }
118
182
 
183
+ // 2. Explicit TmuxDeck Team Manifest
184
+ if (env.AGENT_INTERCOM_TEAM_MANIFEST !== undefined) {
185
+ const rawManifestPath = env.AGENT_INTERCOM_TEAM_MANIFEST;
186
+ if (typeof rawManifestPath !== "string" || !rawManifestPath.trim()) {
187
+ throw new TeamManifestError("ERR_TEAM_MANIFEST_INVALID");
188
+ }
189
+ const manifest = readTeamManifest(rawManifestPath.trim());
190
+ if (!manifest) {
191
+ throw new TeamManifestError("ERR_TEAM_MANIFEST_INVALID");
192
+ }
193
+
194
+ const selfMember = manifest.members.find((member) => member.sessionId === input.selfId);
195
+ if (!selfMember) {
196
+ throw new TeamManifestError("ERR_TEAM_MANIFEST_INVALID");
197
+ }
198
+
199
+ const isManager = input.selfId === manifest.leadId;
200
+ const managerTarget = manifest.leadId;
201
+
202
+ // Coworkers:
203
+ // Lead: all workers
204
+ // Worker: other workers (excluding self and excluding Lead/manager)
205
+ const coworkers: TeamMember[] = manifest.members
206
+ .filter((member) => member.sessionId !== input.selfId && member.sessionId !== manifest.leadId)
207
+ .map((member) => ({
208
+ id: member.sessionId,
209
+ target: member.sessionId,
210
+ role: member.role,
211
+ connected: connectedTo(input.sessions, member.sessionId),
212
+ }));
213
+
214
+ return {
215
+ source: "manifest",
216
+ teamId: manifest.runId,
217
+ self: {
218
+ id: input.selfId,
219
+ isManager,
220
+ role: selfMember.role,
221
+ },
222
+ manager: { target: managerTarget, connected: connectedTo(input.sessions, managerTarget) },
223
+ coworkers,
224
+ };
225
+ }
226
+
227
+ // 3. Workspace Live Same-Scope Non-Human Roster Fallback
228
+ const scopeId = stringValue(env.AGENT_INTERCOM_SCOPE_ID);
229
+ if (scopeId !== undefined) {
230
+ const managerTarget =
231
+ stringValue(env.AGENT_INTERCOM_MANAGER_TARGET) ??
232
+ stringValue(env.AGENT_INTERCOM_MANAGER_SESSION_ID);
233
+ const isManager = !managerTarget || managerTarget === input.selfId;
234
+ const effectiveManagerTarget = isManager ? input.selfId : managerTarget;
235
+
236
+ const coworkers: TeamMember[] = input.sessions
237
+ .filter((session) => session.id !== input.selfId)
238
+ .filter((session) => session.model !== "human")
239
+ .filter((session) => isManager || session.id !== effectiveManagerTarget)
240
+ .map((session) => ({
241
+ id: session.id,
242
+ target: session.id,
243
+ connected: true,
244
+ }));
245
+
246
+ return {
247
+ source: "live_roster",
248
+ teamId: effectiveManagerTarget,
249
+ self: { id: input.selfId, isManager },
250
+ manager: {
251
+ target: effectiveManagerTarget,
252
+ connected: connectedTo(input.sessions, effectiveManagerTarget),
253
+ },
254
+ coworkers,
255
+ };
256
+ }
257
+
258
+ // 4. Standalone Session Fallback
119
259
  return {
120
- teamId,
121
- self: { id: input.selfId, ...(workerId ? { workerId } : {}), isManager },
122
- manager: managerTarget
123
- ? { target: managerTarget, connected: connectedTo(input.sessions, managerTarget) }
124
- : { target: input.selfId, connected: true },
125
- coworkers,
260
+ source: "standalone",
261
+ self: { id: input.selfId, isManager: false },
262
+ coworkers: [],
126
263
  };
127
264
  }
128
265
 
266
+ export function formatIntercomTeam(team: IntercomTeam): string {
267
+ const manager = team.manager
268
+ ? `${team.manager.target} [${team.manager.connected ? "connected" : "not connected"}]`
269
+ : "unknown";
270
+ const lines = [
271
+ `Manager: ${manager}`,
272
+ `You: ${team.self.id}${team.self.isManager ? " [manager]" : ""}`,
273
+ ];
274
+ if (team.controller) {
275
+ lines.push(`Controller: ${team.controller.target} [connected]`);
276
+ }
277
+ if (team.coworkers.length === 0) {
278
+ lines.push("Coworkers: none");
279
+ } else {
280
+ lines.push("Coworkers:");
281
+ for (const coworker of team.coworkers) {
282
+ const metadata = [coworker.harness, coworker.role, coworker.state].filter(Boolean).join(", ");
283
+ lines.push(`- ${coworker.id} target=${coworker.target}${metadata ? ` (${metadata})` : ""} [${coworker.connected ? "connected" : "not connected"}]`);
284
+ }
285
+ }
286
+ return lines.join("\n");
287
+ }
288
+
129
289
  export function resolveBossIntercomTeam(input: {
130
290
  selfId: string;
131
291
  sessions: TeamSession[];
@@ -160,6 +320,7 @@ export function resolveBossIntercomTeam(input: {
160
320
  }
161
321
 
162
322
  return {
323
+ source: "orchestrator",
163
324
  ...(manager ? { teamId: manager.target } : {}),
164
325
  self: { id: input.selfId, isManager },
165
326
  ...(manager ? { manager } : {}),
@@ -167,26 +328,3 @@ export function resolveBossIntercomTeam(input: {
167
328
  coworkers,
168
329
  };
169
330
  }
170
-
171
- export function formatIntercomTeam(team: IntercomTeam): string {
172
- const manager = team.manager
173
- ? `${team.manager.target} [${team.manager.connected ? "connected" : "not connected"}]`
174
- : "unknown";
175
- const lines = [
176
- `Manager: ${manager}`,
177
- `You: ${team.self.id}${team.self.isManager ? " [manager]" : ""}`,
178
- ];
179
- if (team.controller) {
180
- lines.push(`Controller: ${team.controller.target} [connected]`);
181
- }
182
- if (team.coworkers.length === 0) {
183
- lines.push("Coworkers: none");
184
- } else {
185
- lines.push("Coworkers:");
186
- for (const coworker of team.coworkers) {
187
- const metadata = [coworker.harness, coworker.role, coworker.state].filter(Boolean).join(", ");
188
- lines.push(`- ${coworker.id} target=${coworker.target}${metadata ? ` (${metadata})` : ""} [${coworker.connected ? "connected" : "not connected"}]`);
189
- }
190
- }
191
- return lines.join("\n");
192
- }
@@ -0,0 +1,235 @@
1
+ import { execFile } from "node:child_process";
2
+ import { INTERCOM_SCOPE_ENV } from "./protocol-v4/contract.ts";
3
+
4
+ export type JoinMembership = "standalone" | "same-scope" | "tmuxdeck-team" | "orchestrator";
5
+
6
+ export type ScopedWorkspace = {
7
+ sessionName: string;
8
+ scopeId: string;
9
+ };
10
+
11
+ export type TmuxExec = (
12
+ args: string[],
13
+ env?: NodeJS.ProcessEnv,
14
+ ) => Promise<{ ok: boolean; stdout: string }>;
15
+
16
+ export interface ParsedJoinArgs {
17
+ kind: "list" | "workspace" | "index" | "scope";
18
+ workspace?: string;
19
+ index?: number;
20
+ scope?: string;
21
+ }
22
+
23
+ export function isTmuxDeckScope(value: string): boolean {
24
+ if (value.length !== 48) return false;
25
+ for (const char of value) {
26
+ if (!((char >= "0" && char <= "9") || (char >= "a" && char <= "f"))) {
27
+ return false;
28
+ }
29
+ }
30
+ return true;
31
+ }
32
+
33
+ export function isZhLocale(env: NodeJS.ProcessEnv = process.env): boolean {
34
+ const locale = `${env.LC_ALL || env.LC_MESSAGES || env.LANG || ""}`.toLowerCase();
35
+ return locale.startsWith("zh");
36
+ }
37
+
38
+ export function parseJoinArgs(raw: string): ParsedJoinArgs {
39
+ const args = raw.trim().split(/\s+/).filter(Boolean);
40
+ if (args.length === 0) {
41
+ return { kind: "list" };
42
+ }
43
+ if (args[0] === "--scope") {
44
+ if (args.length !== 2 || !args[1]) {
45
+ throw new Error(isZhLocale()
46
+ ? "用法:/intercom-join --scope <48 hex>"
47
+ : "Usage: /intercom-join --scope <48 hex>");
48
+ }
49
+ return { kind: "scope", scope: parseJoinScope(args[1]) };
50
+ }
51
+ if (args.length !== 1 || args[0].startsWith("-")) {
52
+ throw new Error(isZhLocale()
53
+ ? "用法:/intercom-join [工作区名或编号] 或 /intercom-join --scope <48 hex>"
54
+ : "Usage: /intercom-join [workspace or number] or /intercom-join --scope <48 hex>");
55
+ }
56
+ if (/^[1-9]\d*$/.test(args[0])) {
57
+ return { kind: "index", index: Number(args[0]) };
58
+ }
59
+ return { kind: "workspace", workspace: args[0] };
60
+ }
61
+
62
+ export function parseJoinScope(value: string): string {
63
+ if (!isTmuxDeckScope(value)) {
64
+ throw new Error(isZhLocale()
65
+ ? "无法加入该工作区通话圈。"
66
+ : "Could not join that workspace intercom circle.");
67
+ }
68
+ return value;
69
+ }
70
+
71
+ export function classifyMembership(env: NodeJS.ProcessEnv = process.env): JoinMembership {
72
+ if (env.AGENT_INTERCOM_WORKER_ID?.trim() || env.AGENT_INTERCOM_OWNED === "1") {
73
+ return "orchestrator";
74
+ }
75
+ if (env.AGENT_INTERCOM_TEAM_MANIFEST?.trim()) {
76
+ return "tmuxdeck-team";
77
+ }
78
+ if (env.AGENT_INTERCOM_SCOPE_ID?.trim()) {
79
+ return "same-scope";
80
+ }
81
+ return "standalone";
82
+ }
83
+
84
+ export function parseTmuxEnvironmentStdout(stdout: string, varName = INTERCOM_SCOPE_ENV): string | undefined {
85
+ const unsetMarker = `-${varName}`;
86
+ const prefix = `${varName}=`;
87
+ for (const line of stdout.split(/\r?\n/)) {
88
+ const trimmed = line.trim();
89
+ if (trimmed === unsetMarker) {
90
+ return undefined;
91
+ }
92
+ if (trimmed.startsWith(prefix)) {
93
+ const value = trimmed.slice(prefix.length).trim();
94
+ return value || undefined;
95
+ }
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ export function defaultExecTmux(args: string[], env: NodeJS.ProcessEnv = process.env): Promise<{ ok: boolean; stdout: string }> {
101
+ return new Promise((resolve) => {
102
+ execFile("tmux", args, { encoding: "utf8", timeout: 2000, env, shell: false }, (error, stdout) => {
103
+ if (error) {
104
+ resolve({ ok: false, stdout: "" });
105
+ return;
106
+ }
107
+ resolve({ ok: true, stdout: stdout ?? "" });
108
+ });
109
+ });
110
+ }
111
+
112
+ export const tmuxRuntime = {
113
+ exec: defaultExecTmux as TmuxExec,
114
+ };
115
+
116
+ export async function listScopedWorkspaces(options: {
117
+ env?: NodeJS.ProcessEnv;
118
+ execTmux?: TmuxExec;
119
+ } = {}): Promise<ScopedWorkspace[]> {
120
+ const env = options.env ?? process.env;
121
+ const execTmux = options.execTmux ?? tmuxRuntime.exec;
122
+ const listed = await execTmux(["list-sessions", "-F", "#{session_name}"], env);
123
+ if (!listed.ok) {
124
+ return [];
125
+ }
126
+ const names = listed.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
127
+ const workspaces: ScopedWorkspace[] = [];
128
+ for (const sessionName of names) {
129
+ const scopeId = await readSessionScope(sessionName, { env, execTmux });
130
+ if (scopeId) {
131
+ workspaces.push({ sessionName, scopeId });
132
+ }
133
+ }
134
+ return workspaces;
135
+ }
136
+
137
+ export async function readSessionScope(sessionName: string, options: {
138
+ env?: NodeJS.ProcessEnv;
139
+ execTmux?: TmuxExec;
140
+ } = {}): Promise<string | undefined> {
141
+ const env = options.env ?? process.env;
142
+ const execTmux = options.execTmux ?? tmuxRuntime.exec;
143
+ const shown = await execTmux(["show-environment", "-t", sessionName, INTERCOM_SCOPE_ENV], env);
144
+ if (!shown.ok) {
145
+ return undefined;
146
+ }
147
+ const raw = parseTmuxEnvironmentStdout(shown.stdout);
148
+ if (!raw || !isTmuxDeckScope(raw)) {
149
+ return undefined;
150
+ }
151
+ return raw;
152
+ }
153
+
154
+ export async function workspaceNameForScope(scope: string, options: {
155
+ env?: NodeJS.ProcessEnv;
156
+ execTmux?: TmuxExec;
157
+ } = {}): Promise<string | undefined> {
158
+ for (const workspace of await listScopedWorkspaces(options)) {
159
+ if (workspace.scopeId === scope) {
160
+ return workspace.sessionName;
161
+ }
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+ export async function currentTmuxWorkspace(options: {
167
+ env?: NodeJS.ProcessEnv;
168
+ execTmux?: TmuxExec;
169
+ } = {}): Promise<string | undefined> {
170
+ const env = options.env ?? process.env;
171
+ if (!env.TMUX?.trim()) {
172
+ return undefined;
173
+ }
174
+ const execTmux = options.execTmux ?? tmuxRuntime.exec;
175
+ const shown = await execTmux(["display-message", "-p", "#S"], env);
176
+ const name = shown.stdout.trim();
177
+ return name || undefined;
178
+ }
179
+
180
+ export function formatJoinableWorkspaceList(input: {
181
+ workspaces: Array<{ sessionName: string }>;
182
+ zh: boolean;
183
+ }): string {
184
+ if (input.workspaces.length === 0) {
185
+ return input.zh
186
+ ? "没有找到带通话圈的 TmuxDeck 工作区。"
187
+ : "No scoped TmuxDeck workspaces found.";
188
+ }
189
+ const header = input.zh ? "可加入的 TmuxDeck 工作区:" : "Available TmuxDeck workspaces:";
190
+ const footer = input.zh
191
+ ? "请输入编号或精确工作区名(Ctrl-C 取消):"
192
+ : "Select a workspace by number or exact name (Ctrl-C to cancel):";
193
+ return [
194
+ header,
195
+ ...input.workspaces.map((workspace, index) => ` ${index + 1}) ${workspace.sessionName}`),
196
+ footer,
197
+ ].join("\n");
198
+ }
199
+
200
+ export function formatJoinSuccess(input: { workspace: string; name: string; zh: boolean }): string {
201
+ return input.zh
202
+ ? `已加入工作区 ${input.workspace} 的通话圈。\n身份:外部协作者(不是 Team Worker)\n你的显示名:${input.name}`
203
+ : `Joined the intercom circle for workspace ${input.workspace}.\nRole: external collaborator (not a Team Worker)\nDisplay name: ${input.name}`;
204
+ }
205
+
206
+ export function formatJoinStatus(input: {
207
+ membership: JoinMembership;
208
+ workspace?: string;
209
+ name: string;
210
+ peers: string[];
211
+ zh: boolean;
212
+ }): string {
213
+ return input.zh
214
+ ? [
215
+ input.membership,
216
+ `工作区:${input.workspace || "无"}`,
217
+ `显示名:${input.name}`,
218
+ `同圈可见:${input.peers.length > 0 ? input.peers.join(", ") : "无"}`,
219
+ ].join("\n")
220
+ : [
221
+ input.membership,
222
+ `Workspace: ${input.workspace || "none"}`,
223
+ `Display name: ${input.name}`,
224
+ `Visible in circle: ${input.peers.length > 0 ? input.peers.join(", ") : "none"}`,
225
+ ].join("\n");
226
+ }
227
+
228
+ export function rejectManagedJoin(membership: JoinMembership, zh: boolean): string | undefined {
229
+ if (membership === "tmuxdeck-team" || membership === "orchestrator") {
230
+ return zh
231
+ ? "当前会话已是正式成员,不能再加入其他工作区通话圈。"
232
+ : "This session is already a managed member and cannot join another workspace circle.";
233
+ }
234
+ return undefined;
235
+ }