@ctliz/agent-intercom-pi 0.12.0-connect.1 → 0.12.0-connect.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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # Pi Intercom
6
6
 
7
- **Agent Intercom** is a cross-harness, same-machine messaging system for coding agents. Its Pi, Codex, Claude Code, and OpenCode adapters share one local broker and protocol, so sessions can discover and message each other regardless of which harness they run in.
7
+ **Agent Intercom** is a cross-harness, same-machine messaging system for coding agents. Its Pi, Codex, Claude Code, OpenCode, Grok Build, and AGY adapters share one local broker and protocol, so sessions can discover and message each other regardless of which harness they run in.
8
8
 
9
9
  | Harness | Repository |
10
10
  |---|---|
@@ -13,8 +13,12 @@
13
13
  | Codex | [`agent-intercom-codex`](https://github.com/ctliz/agent-intercom-codex) |
14
14
  | Claude Code | [`agent-intercom-claude`](https://github.com/ctliz/agent-intercom-claude) |
15
15
  | OpenCode | [`agent-intercom-opencode`](https://github.com/ctliz/agent-intercom-opencode) |
16
+ | Grok Build | [`agent-intercom-grok`](https://github.com/ctliz/agent-intercom-grok) |
17
+ | AGY | [`agent-intercom-agy`](https://github.com/ctliz/agent-intercom-agy) |
16
18
  | Fleet lifecycle | [`agent-intercom-orchestrator`](https://github.com/ctliz/agent-intercom-orchestrator) |
17
19
 
20
+ Grok Build and AGY use lightweight npm-packaged MCP launchers backed by the Claude MCP runtime. They retain inbound messages for `intercom_pending` polling but do not provide wake-on-message.
21
+
18
22
  ## Maintenance & Upstream Provenance
19
23
 
20
24
  - **Maintained by `ctliz`**: This distribution is maintained independently by [ctliz](https://github.com/ctliz).
@@ -61,7 +65,7 @@ Each pi session that has `pi-intercom` loaded and enabled connects to a tiny loc
61
65
  ## Install
62
66
 
63
67
  ```bash
64
- pi install git:github.com/ctliz/agent-intercom-pi@v0.11.0-connect.2
68
+ pi install git:github.com/ctliz/agent-intercom-pi@v0.12.0-connect.4
65
69
  ```
66
70
 
67
71
  If you are coming from `connect.1`, read [Upgrading from `connect.1`](#upgrading-from-connect1-to-connect2) first — the package namespace changed and the two versions must not be installed side by side.
@@ -71,7 +75,7 @@ Then restart Pi. The extension auto-connects to the broker on startup and regist
71
75
  On a supported Linux host (or WSL with a systemd user manager enabled) you can optionally install the orchestrator Pi plugin, which lets Pi create and safely own persistent Pi, Codex, Claude Code, and OpenCode coworkers. It is not required, and it is not available on macOS:
72
76
 
73
77
  ```bash
74
- pi install git:github.com/ctliz/agent-intercom-orchestrator@v0.11.0-connect.2
78
+ pi install git:github.com/ctliz/agent-intercom-orchestrator@v0.12.0-connect.2
75
79
  ```
76
80
 
77
81
  Restart Pi or run `/reload`, then verify:
@@ -85,7 +89,7 @@ The orchestrator package adds the `agent_fleet` tool, `/agents*` commands, a sco
85
89
 
86
90
  Pi loads the Intercom extension directly, including its native **Alt+I** contact-copy shortcut, so no wrapper command or shell alias is required. You can still alias your usual Pi invocation for convenience, but unlike adapters that need a wrapper to add terminal behavior, an alias does not enable any additional pi-intercom features.
87
91
 
88
- Pi-intercom is also protocol-compatible with the companion Codex, Claude, and OpenCode adapters. They share the same local broker and runtime directory, so sessions from all four hosts appear in the same session list and can send, ask, reply, and recover messages across host boundaries. The first connected adapter can start the broker; Pi does not need to be launched first.
92
+ Pi-intercom is also protocol-compatible with the companion Codex, Claude, OpenCode, Grok Build, and AGY adapters. They share the same local broker and runtime directory, so sessions from all six hosts appear in the same session list and can send, ask, reply, and recover messages across host boundaries. The first connected adapter can start the broker; Pi does not need to be launched first.
89
93
 
90
94
  **Recommended:** Add this snippet to your project's `AGENTS.md` to help agents understand when to coordinate across sessions:
91
95
 
@@ -109,7 +113,7 @@ A session becomes intercom-connected when all of these are true:
109
113
 
110
114
  The session list only shows intercom-connected sessions, not every open Pi process on the machine.
111
115
 
112
- If you upgrade pi-intercom or the orchestrator while sessions are already open, run `/reload` in each open Pi session (and restart any companion `coi`, `cci`, or OpenCode adapter). Update the packages by reinstalling the exact release tags with `pi install git:github.com/ctliz/agent-intercom-pi@v0.11.0-connect.2` and, only where Orchestrator is actually installed, `pi install git:github.com/ctliz/agent-intercom-orchestrator@v0.11.0-connect.2`. Extensions are loaded into the running host process, so an existing session cannot adopt new broker/discovery code until it reloads. This is especially important when upgrading from a release that allowed multiple broker processes to form separate session-list "islands": the broker ownership fix prevents new splits, but it cannot move clients that are still running the old code. After every host has reloaded once, they converge on the same broker automatically.
116
+ If you upgrade pi-intercom or the orchestrator while sessions are already open, run `/reload` in each open Pi session (and restart any companion `coi`, `cci`, or OpenCode adapter). Update the packages by reinstalling the exact release tags with `pi install git:github.com/ctliz/agent-intercom-pi@v0.12.0-connect.4` and, only where Orchestrator is actually installed, `pi install git:github.com/ctliz/agent-intercom-orchestrator@v0.12.0-connect.2`. Extensions are loaded into the running host process, so an existing session cannot adopt new broker/discovery code until it reloads. This is especially important when upgrading from a release that allowed multiple broker processes to form separate session-list "islands": the broker ownership fix prevents new splits, but it cannot move clients that are still running the old code. After every host has reloaded once, they converge on the same broker automatically.
113
117
 
114
118
  If `/intercom` still reports no peers, first confirm the other Pi windows have pi-intercom loaded and have also been reloaded. Open Pi processes without the extension, disabled sessions, and sessions using a different `PI_CODING_AGENT_DIR` intentionally do not appear in the same list.
115
119
 
@@ -218,6 +222,139 @@ Sender identity in `intercom:control.from.id` comes from the broker delivery fra
218
222
 
219
223
  The control bus is intentionally not exposed as a model-facing generic Intercom tool. Companion extensions expose only their specific, reviewed actions.
220
224
 
225
+ ## Teams, names, and who can talk to whom
226
+
227
+ 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.
228
+
229
+ `/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.
230
+
231
+ ### Name a session with `/name`
232
+
233
+ `/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.
234
+
235
+ ```text
236
+ # Terminal 1
237
+ /name planner
238
+
239
+ # Terminal 2
240
+ /name worker
241
+
242
+ # Terminal 3
243
+ /name reviewer
244
+ ```
245
+
246
+ 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`.
247
+
248
+ Check the published name:
249
+
250
+ ```typescript
251
+ intercom_status({})
252
+ intercom_list({})
253
+ ```
254
+
255
+ Copy a host-neutral contact snippet for another agent:
256
+
257
+ ```text
258
+ /intercom-id
259
+ ```
260
+
261
+ That snippet uses the unique name when possible and falls back to the stable session ID when names collide.
262
+
263
+ 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.
264
+
265
+ ### How `intercom_team` chooses a team
266
+
267
+ `intercom_team({})` has no arguments. It resolves the current group in this order and stops at the first match:
268
+
269
+ 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.
270
+ 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.
271
+ 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.
272
+ 4. **Standalone** — no manager and no coworkers. Use `/name` plus `intercom_list` / `intercom_send` instead.
273
+
274
+ Typical result:
275
+
276
+ ```text
277
+ Manager: planner [connected]
278
+ You: worker
279
+ Coworkers:
280
+ - reviewer target=reviewer (pi, worker, running) [connected]
281
+ ```
282
+
283
+ 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.
284
+
285
+ Only an Orchestrator manager may inspect another session's pending inbox, and only with an exact connected coworker `target` from `intercom_team`:
286
+
287
+ ```typescript
288
+ intercom_team({})
289
+ intercom_pending({ session: "tmuxdeck-11111111-2222-4333-8444-555555555555" })
290
+ ```
291
+
292
+ Manifest, live-roster, and standalone teams cannot inspect another session's inbox.
293
+
294
+ ### Join a TmuxDeck circle without becoming a Team Worker
295
+
296
+ `/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.
297
+
298
+ ```text
299
+ /intercom-join # list joinable workspaces
300
+ /intercom-join frontend # join by exact workspace name
301
+ /intercom-join 1 # join by the listed number
302
+ /intercom-join --scope <48hex> # join by the workspace scope
303
+ /intercom-status # confirm membership and visible peers
304
+ ```
305
+
306
+ Managed Team Workers and Orchestrator-owned sessions cannot join another circle. Listing never prints the raw scope.
307
+
308
+ ### Example: two-person billing team
309
+
310
+ Open two terminals in the same repo and start Pi in each.
311
+
312
+ ```text
313
+ # Terminal 1 — the person holding the plan
314
+ /name planner
315
+ intercom_status({})
316
+
317
+ # Terminal 2 — the person making the change
318
+ /name worker
319
+ intercom_list({})
320
+ ```
321
+
322
+ `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`.
323
+
324
+ Planner assigns work:
325
+
326
+ ```typescript
327
+ intercom_send({
328
+ to: "worker",
329
+ message: "Add idempotent refund retries in src/billing/refunds.ts. Ask before changing the public error shape."
330
+ })
331
+ ```
332
+
333
+ Worker asks a blocking question, then reports done with `send`:
334
+
335
+ ```typescript
336
+ intercom_ask({
337
+ to: "planner",
338
+ message: "Refunds currently return 500 on a mid-flight timeout. Should I map that to 409 Conflict?"
339
+ })
340
+ // planner replies in the triggered turn:
341
+ intercom_reply({ message: "Yes. Keep the existing error body; only change the status code." })
342
+
343
+ intercom_send({
344
+ to: "planner",
345
+ message: "Done. Timeouts now return 409. Tests in refunds.test.ts pass."
346
+ })
347
+ ```
348
+
349
+ 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:
350
+
351
+ ```text
352
+ /intercom-join billing
353
+ /name reviewer
354
+ intercom_team({})
355
+ intercom_send({ to: "planner", message: "I can review the refund retry once worker is done." })
356
+ ```
357
+
221
358
  ## Workflow: Planner-Worker Coordination
222
359
 
223
360
  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.4",
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
+ }