@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 +133 -0
- package/broker/spawn.ts +2 -0
- package/index.ts +190 -14
- package/package.json +2 -2
- package/skills/pi-intercom/SKILL.md +9 -0
- package/team.ts +188 -50
- package/workspace-join.ts +235 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
743
|
-
|
|
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
|
|
850
|
+
function syncPresenceIdentity(sessionId?: string): void {
|
|
793
851
|
if (!client || !getLiveContext()) {
|
|
794
852
|
return;
|
|
795
853
|
}
|
|
796
|
-
const
|
|
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
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
self: { id: input.selfId,
|
|
122
|
-
|
|
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
|
+
}
|