@ctliz/agent-intercom-pi 0.12.0-connect.1 → 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,
@@ -591,11 +604,11 @@ interface ClipboardCopyResult {
591
604
  }
592
605
 
593
606
  function runDetachedClipboardCommand(command: string, args: string[], text: string): ClipboardCopyResult {
594
- const found = spawnSync("which", [command], { stdio: "ignore", timeout: 1000 });
607
+ const found = spawnSync("which", [command], { stdio: "ignore", timeout: 1000, shell: false });
595
608
  if (found.status !== 0) return { ok: false, error: `${command} not found` };
596
609
 
597
610
  try {
598
- const proc = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"] });
611
+ const proc = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"], shell: false });
599
612
  proc.stdin.on("error", () => {
600
613
  // Ignore EPIPE if the clipboard helper exits early.
601
614
  });
@@ -616,6 +629,7 @@ function runClipboardCommand(command: string, args: string[], text: string): Cli
616
629
  encoding: "utf8",
617
630
  timeout: 2000,
618
631
  stdio: ["pipe", "ignore", "pipe"],
632
+ shell: false,
619
633
  });
620
634
  if (result.status === 0) return { ok: true, method: command };
621
635
  if (result.error) return { ok: false, error: result.error.message };
@@ -655,7 +669,7 @@ function getNamePollMs(): number {
655
669
  return 1000;
656
670
  }
657
671
  export default function piIntercomExtension(pi: ExtensionAPI) {
658
- const runtimeScopeId = intercomScopeIdFromEnvForRegistration();
672
+ let runtimeScopeId = intercomScopeIdFromEnvForRegistration();
659
673
  const initialHarnessSessionId = process.env[INTERCOM_SESSION_ID_ENV]?.trim();
660
674
  const initialGenericSessionId = process.env.AGENT_INTERCOM_SESSION_ID?.trim();
661
675
  if (initialGenericSessionId && !initialHarnessSessionId) {
@@ -859,6 +873,20 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
859
873
  function publishIntercomSessionId(sessionId: string): void {
860
874
  process.env[INTERCOM_SESSION_ID_ENV] = sessionId;
861
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
+
862
890
  function restoreIntercomSessionId(): void {
863
891
  if (previousIntercomSessionId === undefined) {
864
892
  delete process.env[INTERCOM_SESSION_ID_ENV];
@@ -2861,6 +2889,107 @@ Usage:
2861
2889
  },
2862
2890
  });
2863
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
+
2864
2993
  pi.registerShortcut("alt+m", {
2865
2994
  description: "Open session intercom",
2866
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.12.0-connect.1",
3
+ "version": "0.12.0-connect.3",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -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
@@ -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
+ }