@ctliz/agent-intercom-pi 0.12.2 → 0.14.0

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/index.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { intercomOutputSchema, structuredIntercomResult } from "./tool-result.ts";
2
3
  import { StringEnum } from "@earendil-works/pi-ai";
3
4
  import { randomUUID } from "crypto";
4
5
  import { spawn, spawnSync } from "child_process";
@@ -15,7 +16,8 @@ import { formatMessageTiming } from "./ui/timestamps.ts";
15
16
  import { formatSessionDisplayName, sanitizeDisplayText, sessionOriginLabel, shortestUniqueIdPrefixes } from "./ui/session-identity.ts";
16
17
  import { getAskTimeoutMs, getAskWaitMs, loadConfig, type IntercomConfig } from "./config.ts";
17
18
  import type { SessionInfo, SessionRegistration, Message, Attachment } from "./types.ts";
18
- import { pendingAskId, ReplyTracker, type IntercomContext } from "./reply-tracker.ts";
19
+ import { pendingAskId, replyContextId, ReplyTracker, type IntercomContext } from "./reply-tracker.ts";
20
+ import { appendNamedTeamMembership, formatNamedTeamRoster, namedTeamRoster, requireNamedTeamMembers, resolveNamedMessageTeam, sessionNamedTeams } from "./named-team-membership.ts";
19
21
  import { InboundMessageConflictError, PersistentInboundInbox, readPendingAsksSnapshot, type StoredInboundMessage } from "./inbound-inbox.ts";
20
22
  import { PersistentOutboundOutbox } from "./outbound-outbox.ts";
21
23
  import { formatIntercomTeam, resolveBossIntercomTeam, resolveIntercomTeam, resolveManagedInboxSession } from "./team.ts";
@@ -33,7 +35,6 @@ import {
33
35
  } from "./workspace-join.ts";
34
36
  import {
35
37
  buildJoinableCircles,
36
- createNamedTeam,
37
38
  findNamedTeamByScope,
38
39
  formatCreateSuccess,
39
40
  formatJoinableCircleList,
@@ -147,9 +148,8 @@ function toError(error: unknown): Error {
147
148
  }
148
149
 
149
150
  function toolErrorDetails(error: unknown): { error: true; code?: string } {
150
- return error instanceof BossTeamScopeError
151
- ? { error: true, code: error.code }
152
- : { error: true };
151
+ const code = (error as { code?: unknown } | null)?.code;
152
+ return { error: true, ...(typeof code === "string" ? { code } : {}) };
153
153
  }
154
154
 
155
155
  class AskWaitElapsedError extends Error {
@@ -680,6 +680,15 @@ function getNamePollMs(): number {
680
680
  return 1000;
681
681
  }
682
682
  export default function piIntercomExtension(pi: ExtensionAPI) {
683
+ function registerIntercomTool(tool: ToolDefinition<any, any>): void {
684
+ pi.registerTool({
685
+ ...tool,
686
+ outputSchema: intercomOutputSchema,
687
+ async execute(...args) {
688
+ return structuredIntercomResult(await tool.execute(...args));
689
+ },
690
+ });
691
+ }
683
692
  let runtimeScopeId = intercomScopeIdFromEnvForRegistration();
684
693
  const initialHarnessSessionId = process.env[INTERCOM_SESSION_ID_ENV]?.trim();
685
694
  const initialGenericSessionId = process.env.AGENT_INTERCOM_SESSION_ID?.trim();
@@ -706,6 +715,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
706
715
  let reconnectPromiseGeneration: number | null = null;
707
716
  let startupConnectTimer: NodeJS.Timeout | null = null;
708
717
  let reconnectAttempt = 0;
718
+ let registrationConflict: Error | null = null;
709
719
  let shuttingDown = false;
710
720
  let disposed = true;
711
721
  let runtimeStarted = false;
@@ -722,13 +732,14 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
722
732
  const replyWaiters = new Map<string, {
723
733
  from: string;
724
734
  replyTo: string;
735
+ team?: string;
725
736
  resolve: (message: Message) => void;
726
737
  reject: (error: Error) => void;
727
738
  }>();
728
739
  function hasReplyWaiterForTarget(from: string): boolean {
729
740
  return Array.from(replyWaiters.values()).some((waiter) => waiter.from.toLowerCase() === from.toLowerCase());
730
741
  }
731
- function waitForReply(from: string, replyTo: string, signal?: AbortSignal, onCancel?: () => void): Promise<Message> {
742
+ function waitForReply(from: string, replyTo: string, signal?: AbortSignal, onCancel?: () => void, team?: string): Promise<Message> {
732
743
  if (hasReplyWaiterForTarget(from)) {
733
744
  return Promise.reject(new Error(`Already waiting for a reply from "${from}"`));
734
745
  }
@@ -753,6 +764,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
753
764
  replyWaiters.set(replyTo, {
754
765
  from,
755
766
  replyTo,
767
+ team,
756
768
  resolve: (message) => {
757
769
  cleanup();
758
770
  resolve(message);
@@ -835,7 +847,8 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
835
847
  }
836
848
  function currentStatus(): string {
837
849
  const activeToolName = activeTools.values().next().value;
838
- const lifecycleStatus = activeToolName ? `tool:${activeToolName}` : agentRunning ? "thinking" : "idle";
850
+ const busy = agentRunning || getLiveContext()?.isIdle() === false;
851
+ const lifecycleStatus = activeToolName ? `tool:${activeToolName}` : busy ? "thinking" : "idle";
839
852
  const queueStatus = inboundInbox?.size ? ` · inbox:${inboundInbox.size}` : "";
840
853
  const outboxStatus = client?.outboxSize ? ` · outbox:${client.outboxSize}` : "";
841
854
  return config.status ? `${lifecycleStatus}${queueStatus}${outboxStatus} · ${config.status}` : `${lifecycleStatus}${queueStatus}${outboxStatus}`;
@@ -910,6 +923,9 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
910
923
  }
911
924
  return rejectManagedJoin(classifyMembership(), isZhLocale());
912
925
  }
926
+ function taskTeamsAvailable(): boolean {
927
+ return !rejectCircleChange();
928
+ }
913
929
  async function applyCircleScope(ctx: ExtensionContext, scopeId: string, managerSessionId?: string): Promise<void> {
914
930
  if (managerSessionId) {
915
931
  process.env.AGENT_INTERCOM_MANAGER_TARGET = managerSessionId;
@@ -922,16 +938,22 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
922
938
  await switchRuntimeScope(scopeId);
923
939
  syncPresenceIdentity(currentSessionId ?? ctx.sessionManager.getSessionId());
924
940
  }
925
- async function createAndJoinNamedTeam(ctx: ExtensionContext, name: string): Promise<string> {
941
+ async function appendTaskTeam(ctx: ExtensionContext, name: string, create = false, members: string[] = [], work?: string) {
926
942
  const blocked = rejectCircleChange();
927
943
  if (blocked) throw new Error(blocked);
928
944
  const teamName = parseTeamName(name);
929
- if (!getLiveContext(ctx)) {
930
- startSessionRuntime(ctx);
945
+ if (!getLiveContext(ctx)) startSessionRuntime(ctx);
946
+ const activeClient = await ensureConnected("tool");
947
+ const memberIds: string[] = [];
948
+ for (const target of members) {
949
+ const id = await resolveSessionTarget(activeClient, target);
950
+ if (!id) throw new Error(`Team member "${target}" is not connected`);
951
+ memberIds.push(id);
931
952
  }
932
- const managerSessionId = currentSessionId ?? ctx.sessionManager.getSessionId();
933
- const team = createNamedTeam({ name: teamName, managerSessionId });
934
- await applyCircleScope(ctx, team.scopeId, team.managerSessionId);
953
+ return appendNamedTeamMembership({ name: teamName, selfId: activeClient.sessionId!, create, members: memberIds, work });
954
+ }
955
+ async function createAndJoinNamedTeam(ctx: ExtensionContext, name: string): Promise<string> {
956
+ const team = await appendTaskTeam(ctx, name, true);
935
957
  return formatCreateSuccess({ team: team.name, name: currentDisplayName(), zh: isZhLocale() });
936
958
  }
937
959
  async function listOrJoinCircle(ctx: ExtensionContext, args: string): Promise<string> {
@@ -962,10 +984,12 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
962
984
  ? (zh ? "没有这个编号的团队。" : "No team uses that number.")
963
985
  : (zh ? "无法加入该团队。" : "Could not join that team."));
964
986
  }
987
+ if (selected.kind === "named") {
988
+ await appendTaskTeam(ctx, selected.name);
989
+ return formatNamedJoinSuccess({ team: selected.name, name: currentDisplayName(), zh });
990
+ }
965
991
  await applyCircleScope(ctx, selected.scopeId, selected.managerSessionId);
966
- return selected.kind === "named"
967
- ? formatNamedJoinSuccess({ team: selected.name, name: currentDisplayName(), zh })
968
- : formatJoinSuccess({ workspace: selected.name, name: currentDisplayName(), zh });
992
+ return formatJoinSuccess({ workspace: selected.name, name: currentDisplayName(), zh });
969
993
  }
970
994
 
971
995
  function restoreIntercomSessionId(): void {
@@ -1010,11 +1034,13 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1010
1034
  const attachmentText = stored.message.content.attachments?.length
1011
1035
  ? formatAttachments(stored.message.content.attachments)
1012
1036
  : "";
1013
- const replyCommand = config.replyHint && stored.message.expectsReply
1014
- ? batchSize === 1
1015
- ? `intercom_reply({ message: "..." })`
1016
- : `intercom_reply({ to: "${stored.from.id}", message: "..." })`
1017
- : undefined;
1037
+ const replyCommand = config.replyHint && stored.message.content.team
1038
+ ? `intercom_reply({ contextId: "${replyContextId(stored.from.id, stored.message.id)}", message: "..." })`
1039
+ : config.replyHint && stored.message.expectsReply
1040
+ ? batchSize === 1
1041
+ ? `intercom_reply({ message: "..." })`
1042
+ : `intercom_reply({ to: "${stored.from.id}", message: "..." })`
1043
+ : undefined;
1018
1044
  return {
1019
1045
  key: stored.key,
1020
1046
  from: stored.from,
@@ -1028,7 +1054,8 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1028
1054
  const senderDisplay = formatSessionDisplayName(entry.from);
1029
1055
  const prefix = position ? `[${position.index}/${position.total}] ` : "";
1030
1056
  const replyInstruction = entry.replyCommand ? `\n\nTo reply, use: ${entry.replyCommand}` : "";
1031
- return `**${prefix}📨 From ${senderDisplay}** (${entry.from.cwd})${replyInstruction}\n\n${entry.bodyText}`;
1057
+ const teamLabel = entry.message.content.team ? `[Team: ${sanitizeDisplayText(entry.message.content.team)}] ` : "";
1058
+ return `**${prefix}${teamLabel}📨 From ${senderDisplay}** (${entry.from.cwd})${replyInstruction}\n\n${entry.bodyText}`;
1032
1059
  }
1033
1060
  function sendIncomingBatch(entries: InboundMessageEntry[], generation = runtimeGeneration, forceTrigger = false): void {
1034
1061
  if (entries.length === 0) return;
@@ -1079,6 +1106,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1079
1106
  inboundFlushTimer.unref?.();
1080
1107
  }
1081
1108
  function flushIdleMessages(generation = runtimeGeneration): void {
1109
+ if (registrationConflict) return;
1082
1110
  if (!inboundInbox || inboundInbox.size === 0) {
1083
1111
  return;
1084
1112
  }
@@ -1086,6 +1114,10 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1086
1114
  if (!ctx) {
1087
1115
  return;
1088
1116
  }
1117
+ if (!client?.isConnected()) {
1118
+ scheduleInboundFlush(INBOUND_IDLE_RETRY_MS);
1119
+ return;
1120
+ }
1089
1121
 
1090
1122
  let isIdle: boolean;
1091
1123
  try {
@@ -1194,6 +1226,18 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1194
1226
  });
1195
1227
  return;
1196
1228
  }
1229
+ try {
1230
+ if (message.content.team !== undefined) {
1231
+ if (!taskTeamsAvailable()) throw new Error("Named task-team messages are unavailable in managed-team mode");
1232
+ requireNamedTeamMembers(message.content.team, receivingClient.sessionId!, from.id);
1233
+ }
1234
+ const waiter = message.replyTo ? replyWaiters.get(message.replyTo) : undefined;
1235
+ if (waiter && waiter.team !== message.content.team) throw new Error("Reply team does not match the original ask");
1236
+ } catch (error) {
1237
+ receivingClient.rejectMessage(deliveryId, getErrorMessage(error));
1238
+ pi.appendEntry("intercom_inbox_team_denied", { from: from.id, messageId: message.id, error: getErrorMessage(error), timestamp: Date.now() });
1239
+ return;
1240
+ }
1197
1241
  let enqueued;
1198
1242
  try {
1199
1243
  enqueued = inboxAtReceive.enqueue(from, message);
@@ -1305,7 +1349,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1305
1349
  });
1306
1350
  }
1307
1351
  function scheduleReconnect(): void {
1308
- if (disposed || shuttingDown || reconnectTimer || reconnectPromise || !getLiveContext()) {
1352
+ if (disposed || shuttingDown || registrationConflict || reconnectTimer || reconnectPromise || !getLiveContext()) {
1309
1353
  return;
1310
1354
  }
1311
1355
  const scheduledGeneration = runtimeGeneration;
@@ -1327,6 +1371,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1327
1371
  if (disposed || shuttingDown) {
1328
1372
  throw new Error("Intercom shutting down");
1329
1373
  }
1374
+ if (registrationConflict) throw registrationConflict;
1330
1375
  if (bossTeamScope.present) {
1331
1376
  const selfError = currentSessionId ? bossSelfSessionError(bossTeamScope, currentSessionId) : "Boss session identity is unavailable";
1332
1377
  if (selfError) throw new BossTeamScopeError("BOSS_TEAM_METADATA_INVALID", selfError);
@@ -1359,19 +1404,24 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1359
1404
  }
1360
1405
  client = nextClient;
1361
1406
  reconnectAttempt = 0;
1407
+ scheduleInboundFlush(0);
1362
1408
  return nextClient;
1363
1409
  } catch (error) {
1364
1410
  if (client === nextClient) {
1365
1411
  client = null;
1366
1412
  }
1367
- if (reason === "background" && getLiveContext(contextAtStart, generationAtStart)) {
1368
- scheduleReconnect();
1413
+ if ((error as { code?: string })?.code === "SESSION_ID_IN_USE" && getLiveContext(contextAtStart, generationAtStart)) {
1414
+ registrationConflict = toError(error);
1415
+ clearReconnectTimer();
1416
+ pi.appendEntry("intercom_registration_conflict", { sessionId: currentSessionId, code: "SESSION_ID_IN_USE", timestamp: Date.now() });
1417
+ notifyIfLive(contextAtStart, "Intercom session is owned by another runtime. Switch to a different session, or release the duplicate owner and /reload. The existing owner was not changed.", "warning", generationAtStart);
1369
1418
  }
1370
1419
  throw toError(error);
1371
1420
  } finally {
1372
1421
  if (reconnectPromise === nextReconnectPromise) {
1373
1422
  reconnectPromise = null;
1374
1423
  reconnectPromiseGeneration = null;
1424
+ if (reason === "background" && !client) scheduleReconnect();
1375
1425
  }
1376
1426
  }
1377
1427
  })();
@@ -1490,6 +1540,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1490
1540
  runtimeStarted = true;
1491
1541
  runtimeGeneration += 1;
1492
1542
  reconnectAttempt = 0;
1543
+ registrationConflict = null;
1493
1544
  clearReconnectTimer();
1494
1545
  clearStartupConnectTimer();
1495
1546
  clearNamePollTimer();
@@ -1810,11 +1861,16 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1810
1861
  activeTools.delete(event.toolCallId);
1811
1862
  syncPresenceStatus();
1812
1863
  });
1813
- pi.on("agent_end", () => {
1814
- if (!getLiveContext()) {
1815
- return;
1816
- }
1864
+ pi.on("agent_end", (_event, ctx) => {
1865
+ if (!getLiveContext(ctx)) return;
1817
1866
  replyTracker.endTurn();
1867
+ agentRunning = !ctx.isIdle();
1868
+ activeTools.clear();
1869
+ syncPresenceStatus();
1870
+ scheduleInboundFlush(0);
1871
+ });
1872
+ pi.on("agent_settled", (_event, ctx) => {
1873
+ if (!getLiveContext(ctx)) return;
1818
1874
  agentRunning = false;
1819
1875
  activeTools.clear();
1820
1876
  syncPresenceStatus();
@@ -1895,7 +1951,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1895
1951
 
1896
1952
  const childOrchestratorMetadata = readChildOrchestratorMetadata();
1897
1953
  if (childOrchestratorMetadata) {
1898
- pi.registerTool({
1954
+ registerIntercomTool({
1899
1955
  name: "contact_supervisor",
1900
1956
  label: "Contact Supervisor",
1901
1957
  description: "Subagent-only tool for contacting the supervisor agent that delegated this task. Use need_decision when blocked, uncertain, needing approval, or facing a product/API/scope decision before continuing; this waits up to 30 seconds, then continues asynchronously if unanswered. Use interview_request when multiple structured questions need supervisor answers; it has the same soft wait. Use progress_update only for meaningful progress or unexpected discoveries that change the plan; this does not wait for a reply. Do not use for routine completion handoffs.",
@@ -2198,6 +2254,8 @@ Usage:
2198
2254
  message: Type.Optional(Type.String({
2199
2255
  description: "Message to send (for 'send', 'ask', or 'reply' action)",
2200
2256
  })),
2257
+ team: Type.Optional(Type.String({ description: "Task team for sending or selecting a reply; replies cannot change the original team" })),
2258
+ contextId: Type.Optional(Type.String({ description: "Exact inbound message selector from its reply hint" })),
2201
2259
  attachments: Type.Optional(Type.Array(Type.Object({
2202
2260
  type: StringEnum(["file", "snippet", "context"] as const),
2203
2261
  name: Type.String(),
@@ -2219,6 +2277,12 @@ Usage:
2219
2277
  }),
2220
2278
 
2221
2279
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2280
+ if (params.action === "status" && registrationConflict) {
2281
+ return {
2282
+ content: [{ type: "text", text: `**Intercom Status:**\nConnected: No\nSession ID: ${currentSessionId}\nState: SESSION_ID_IN_USE\nAnother runtime owns this session; automatic reconnect is paused. Release the duplicate owner and /reload, or switch to a different session.` }],
2283
+ details: { connected: false, sessionId: currentSessionId, code: "SESSION_ID_IN_USE", error: true },
2284
+ };
2285
+ }
2222
2286
  let connectedClient: IntercomClient;
2223
2287
  try {
2224
2288
  connectedClient = await ensureConnected("tool");
@@ -2241,7 +2305,7 @@ Usage:
2241
2305
  }
2242
2306
  }
2243
2307
 
2244
- const { action, to, message, attachments, replyTo, askId, session, which } = params;
2308
+ const { action, to, message, attachments, replyTo, askId, session, which, team, contextId } = params;
2245
2309
 
2246
2310
  switch (action) {
2247
2311
  case "list": {
@@ -2272,7 +2336,7 @@ Usage:
2272
2336
 
2273
2337
  return {
2274
2338
  content: [{ type: "text", text: `${currentSection}\n\n${otherSection}` }],
2275
- details: {},
2339
+ details: { sessionId: mySessionId, sessions },
2276
2340
  };
2277
2341
  } catch (error) {
2278
2342
  return {
@@ -2303,6 +2367,10 @@ Usage:
2303
2367
  details: { error: true },
2304
2368
  };
2305
2369
  }
2370
+ const messageTeam = taskTeamsAvailable()
2371
+ ? resolveNamedMessageTeam(connectedClient.sessionId!, sendTo, team)
2372
+ : undefined;
2373
+ if (!taskTeamsAvailable() && team !== undefined) throw new Error("Named task teams are unavailable in managed-team mode");
2306
2374
  if (!replyTo && config.confirmSend && ctx.hasUI) {
2307
2375
  const attachmentText = attachments?.length ? formatAttachments(attachments) : "";
2308
2376
  const confirmed = await ctx.ui.confirm(
@@ -2318,6 +2386,7 @@ Usage:
2318
2386
  }
2319
2387
  const result = await connectedClient.send(sendTo, {
2320
2388
  text: message,
2389
+ team: messageTeam,
2321
2390
  attachments,
2322
2391
  replyTo,
2323
2392
  });
@@ -2330,7 +2399,7 @@ Usage:
2330
2399
  }
2331
2400
  pi.appendEntry("intercom_sent", {
2332
2401
  to,
2333
- message: { text: message, attachments, replyTo },
2402
+ message: { text: message, attachments, replyTo, team: messageTeam },
2334
2403
  messageId: result.id,
2335
2404
  timestamp: Date.now(),
2336
2405
  });
@@ -2339,8 +2408,8 @@ Usage:
2339
2408
  inboundInbox?.dismissPendingAsk(replyTo, sendTo);
2340
2409
  }
2341
2410
  return {
2342
- content: [{ type: "text", text: `Message sent to ${to}` }],
2343
- details: deliveryResultDetails(result),
2411
+ content: [{ type: "text", text: `Message sent to ${to}${messageTeam ? ` [Team: ${messageTeam}]` : ""}` }],
2412
+ details: deliveryResultDetails(result, messageTeam ? { team: messageTeam } : {}),
2344
2413
  };
2345
2414
  } catch (error) {
2346
2415
  return {
@@ -2372,6 +2441,7 @@ Usage:
2372
2441
  }
2373
2442
  let replyPromise: Promise<Message> | null = null;
2374
2443
  let questionId: string | null = null;
2444
+ let messageTeam: string | undefined;
2375
2445
 
2376
2446
  try {
2377
2447
  const sendTo = await resolveAuthorizedTarget(connectedClient, to);
@@ -2393,12 +2463,17 @@ Usage:
2393
2463
  details: { error: true },
2394
2464
  };
2395
2465
  }
2466
+ messageTeam = taskTeamsAvailable()
2467
+ ? resolveNamedMessageTeam(connectedClient.sessionId!, sendTo, team)
2468
+ : undefined;
2469
+ if (!taskTeamsAvailable() && team !== undefined) throw new Error("Named task teams are unavailable in managed-team mode");
2396
2470
  questionId = randomUUID();
2397
- replyPromise = waitForReply(sendTo, questionId, _signal, () => { void connectedClient.cancelAsk(questionId!); });
2471
+ replyPromise = waitForReply(sendTo, questionId, _signal, () => { void connectedClient.cancelAsk(questionId!); }, messageTeam);
2398
2472
  replyPromise.catch(() => undefined);
2399
2473
  const sendResult = await connectedClient.send(sendTo, {
2400
2474
  messageId: questionId,
2401
2475
  text: message,
2476
+ team: messageTeam,
2402
2477
  attachments,
2403
2478
  replyTo,
2404
2479
  expectsReply: true,
@@ -2421,7 +2496,7 @@ Usage:
2421
2496
  }
2422
2497
  pi.appendEntry("intercom_sent", {
2423
2498
  to,
2424
- message: { text: message, attachments, replyTo },
2499
+ message: { text: message, attachments, replyTo, team: messageTeam },
2425
2500
  messageId: sendResult.id,
2426
2501
  timestamp: Date.now(),
2427
2502
  });
@@ -2432,17 +2507,18 @@ Usage:
2432
2507
  : "";
2433
2508
  pi.appendEntry("intercom_received", {
2434
2509
  from: to,
2435
- message: { text: replyText, attachments: replyMessage.content.attachments },
2510
+ message: { text: replyText, team: replyMessage.content.team, attachments: replyMessage.content.attachments },
2436
2511
  messageId: replyMessage.id,
2437
2512
  timestamp: replyMessage.timestamp,
2438
2513
  });
2439
2514
  return {
2440
- content: [{ type: "text", text: `**Reply from ${to}:**\n${replyText}${replyAttachments}` }],
2441
- details: {},
2515
+ content: [{ type: "text", text: `**${messageTeam ? `[Team: ${messageTeam}] ` : ""}Reply from ${to}:**\n${replyText}${replyAttachments}` }],
2516
+ details: messageTeam ? { team: messageTeam } : {},
2442
2517
  };
2443
2518
  } catch (error) {
2444
2519
  if (error instanceof AskWaitElapsedError) {
2445
- return deferredAskResult(to, error, await connectedClient.deferAsk(error.replyTo));
2520
+ const deferred = deferredAskResult(to, error, await connectedClient.deferAsk(error.replyTo));
2521
+ return { ...deferred, details: { ...deferred.details, ...(messageTeam ? { team: messageTeam } : {}) } };
2446
2522
  }
2447
2523
  if (questionId) rejectReplyWaiter(questionId, toError(error));
2448
2524
  if (replyPromise) {
@@ -2474,7 +2550,9 @@ Usage:
2474
2550
  if ("code" in requested) throw new BossTeamScopeError(requested.code, requested.error);
2475
2551
  exactReplyTarget = requested.targetId;
2476
2552
  }
2477
- const target = replyTracker.resolveReplyTarget({ to: exactReplyTarget, replyTo, askId, which });
2553
+ const target = replyTracker.resolveReplyTarget({ to: exactReplyTarget, replyTo, askId, which, team, contextId });
2554
+ const replyTeam = target.message.content.team;
2555
+ if (replyTeam !== undefined) requireNamedTeamMembers(replyTeam, connectedClient.sessionId!, target.from.id);
2478
2556
  if (bossTeamScope.present) {
2479
2557
  const resolution = resolveBossLiveTarget(bossTeamScope, target.from.id, await connectedClient.listSessions(), connectedClient.sessionId);
2480
2558
  if ("code" in resolution) throw new BossTeamScopeError(resolution.code, resolution.error);
@@ -2488,6 +2566,7 @@ Usage:
2488
2566
  const threadedReplyTo = target.message.expectsReply ? target.message.id : undefined;
2489
2567
  const result = await connectedClient.send(target.from.id, {
2490
2568
  text: message,
2569
+ team: replyTeam,
2491
2570
  ...(threadedReplyTo ? { replyTo: threadedReplyTo } : {}),
2492
2571
  });
2493
2572
  if (!result.delivered) {
@@ -2505,17 +2584,17 @@ Usage:
2505
2584
  replyTracker.markReplied(threadedReplyTo, target.from.id);
2506
2585
  inboundInbox?.dismissPendingAsk(threadedReplyTo, target.from.id);
2507
2586
  } else {
2508
- replyTracker.dismissOrdinarySender(target.from.id);
2587
+ replyTracker.dismissOrdinarySender(target.from.id, target.message.id);
2509
2588
  }
2510
2589
  pi.appendEntry("intercom_sent", {
2511
2590
  to: bossTeamScope.present ? target.from.id : target.from.name || target.from.id,
2512
- message: { text: message, ...(threadedReplyTo ? { replyTo: threadedReplyTo } : {}) },
2591
+ message: { text: message, team: replyTeam, ...(threadedReplyTo ? { replyTo: threadedReplyTo } : {}) },
2513
2592
  messageId: result.id,
2514
2593
  timestamp: Date.now(),
2515
2594
  });
2516
2595
  return {
2517
- content: [{ type: "text", text: `Reply sent to ${bossTeamScope.present ? target.from.id : target.from.name || target.from.id}` }],
2518
- details: deliveryResultDetails(result, threadedReplyTo ? { replyTo: threadedReplyTo } : {}),
2596
+ content: [{ type: "text", text: `Reply sent to ${bossTeamScope.present ? target.from.id : target.from.name || target.from.id}${replyTeam ? ` [Team: ${replyTeam}]` : ""}` }],
2597
+ details: deliveryResultDetails(result, { ...(threadedReplyTo ? { replyTo: threadedReplyTo } : {}), ...(replyTeam ? { team: replyTeam } : {}) }),
2519
2598
  };
2520
2599
  } catch (error) {
2521
2600
  return {
@@ -2587,7 +2666,7 @@ Usage:
2587
2666
  : "";
2588
2667
  const sender = bossTeamScope.present ? ask.from.id : ask.from.name || ask.from.id;
2589
2668
  return {
2590
- content: [{ type: "text", text: `**Pending ask ${ask.id}**\nInbox: ${inboxSessionId}\nFrom: ${sender}\n\n${ask.message.content.text}${attachmentText}` }],
2669
+ content: [{ type: "text", text: `**Pending ask ${ask.id}**\nInbox: ${inboxSessionId}\nFrom: ${sender}${ask.message.content.team ? `\nTeam: ${ask.message.content.team}` : ""}\n\n${ask.message.content.text}${attachmentText}` }],
2591
2670
  details: {
2592
2671
  inboxSessionId,
2593
2672
  ask: {
@@ -2597,6 +2676,7 @@ Usage:
2597
2676
  ...(ask.deferredAt ? { deferredAt: ask.deferredAt } : {}),
2598
2677
  preview: ask.preview,
2599
2678
  body: ask.message.content.text,
2679
+ ...(ask.message.content.team ? { team: ask.message.content.team } : {}),
2600
2680
  attachments: ask.message.content.attachments ?? [],
2601
2681
  },
2602
2682
  },
@@ -2606,7 +2686,7 @@ Usage:
2606
2686
  const lines = asks.map((ask) => {
2607
2687
  const state = ask.deferredAt ? " · async" : "";
2608
2688
  const sender = bossTeamScope.present ? ask.from.id : ask.from.name || ask.from.id;
2609
- return `- ${ask.id} · ${sender}${ask.selector} · ${ask.elapsedSeconds}s ago${state} · ${ask.preview}`;
2689
+ return `- ${ask.id} · ${sender}${ask.message.content.team ? ` · Team: ${ask.message.content.team}` : ""}${ask.selector} · ${ask.elapsedSeconds}s ago${state} · ${ask.preview}`;
2610
2690
  });
2611
2691
  return {
2612
2692
  content: [{ type: "text", text: `**Pending asks:**\n${lines.join("\n")}` }],
@@ -2618,6 +2698,7 @@ Usage:
2618
2698
  receivedAt: ask.receivedAt,
2619
2699
  ...(ask.deferredAt ? { deferredAt: ask.deferredAt } : {}),
2620
2700
  preview: ask.preview,
2701
+ ...(ask.message.content.team ? { team: ask.message.content.team } : {}),
2621
2702
  })),
2622
2703
  },
2623
2704
  };
@@ -2632,7 +2713,7 @@ Usage:
2632
2713
  type: "text",
2633
2714
  text: `**Intercom Status:**\nConnected: Yes\nSession ID: ${mySessionId}\nActive sessions: ${sessions.length}\nQueued inbound messages: ${inboundInbox?.size ?? 0}\nQueued outbound messages: ${connectedClient.outboxSize}\nPending inbound asks: ${replyTracker.listPending().length}`,
2634
2715
  }],
2635
- details: {},
2716
+ details: { connected: true, sessionId: mySessionId, activeSessions: sessions.length, inboundMessages: inboundInbox?.size ?? 0, outboundMessages: connectedClient.outboxSize, pendingAsks: replyTracker.listPending().length },
2636
2717
  };
2637
2718
  } catch (error) {
2638
2719
  return {
@@ -2705,7 +2786,7 @@ Usage:
2705
2786
  legacyIntercomTool.renderCall({ ...args, action }, theme, context);
2706
2787
  const renderSplitResult = legacyIntercomTool.renderResult;
2707
2788
 
2708
- pi.registerTool({
2789
+ registerIntercomTool({
2709
2790
  name: "intercom_send",
2710
2791
  label: "Intercom Send",
2711
2792
  description: "Send a message to another local Pi session. Both the recipient and message are required.",
@@ -2713,6 +2794,7 @@ Usage:
2713
2794
  promptGuidelines: ["Use intercom_send for progress updates, notifications, and other messages that do not require a conversational reply."],
2714
2795
  parameters: Type.Object({
2715
2796
  to: Type.String({ description: "Required recipient session name or stable session ID" }),
2797
+ team: Type.Optional(Type.String({ description: "Task team name. Required when both sessions share multiple teams." })),
2716
2798
  message: Type.String({ description: "Required message to send" }),
2717
2799
  attachments: attachmentParameters,
2718
2800
  }),
@@ -2721,7 +2803,7 @@ Usage:
2721
2803
  renderResult: renderSplitResult,
2722
2804
  } as any);
2723
2805
 
2724
- pi.registerTool({
2806
+ registerIntercomTool({
2725
2807
  name: "intercom_ask",
2726
2808
  label: "Intercom Ask",
2727
2809
  description: "Ask another local Pi session a blocking question, waiting briefly before continuing asynchronously. Do not use this for progress or status checkpoints.",
@@ -2729,6 +2811,7 @@ Usage:
2729
2811
  promptGuidelines: ["Use intercom_ask only when work genuinely depends on another session's answer. Use intercom_send for progress checks, status requests, assignments, notifications, and follow-ups. Different recipients may be asked concurrently, but keep only one unresolved ask per recipient."],
2730
2812
  parameters: Type.Object({
2731
2813
  to: Type.String({ description: "Required recipient session name or stable session ID" }),
2814
+ team: Type.Optional(Type.String({ description: "Task team name. Required when both sessions share multiple teams." })),
2732
2815
  message: Type.String({ description: "Required question to ask" }),
2733
2816
  attachments: attachmentParameters,
2734
2817
  }),
@@ -2737,31 +2820,33 @@ Usage:
2737
2820
  renderResult: renderSplitResult,
2738
2821
  } as any);
2739
2822
 
2740
- pi.registerTool({
2823
+ registerIntercomTool({
2741
2824
  name: "intercom_reply",
2742
2825
  label: "Intercom Reply",
2743
2826
  description: "Reply to an inbound intercom message or ask. Exact protocol threading is resolved internally.",
2744
2827
  promptSnippet: "Reply to the active or pending inbound intercom message.",
2745
- promptGuidelines: ["Use intercom_reply to answer an inbound intercom message. Prefer the stable `askId` returned by intercom_pending when selecting an exact ask; `to` plus `which` remains available for compatibility."],
2828
+ promptGuidelines: ["Use intercom_reply to answer an inbound message, inheriting its original team. Prefer its `contextId` reply hint or the `askId` from intercom_pending. Never route replies using a mutable current team or mix work from different teams."],
2746
2829
  parameters: Type.Object({
2747
2830
  message: Type.String({ description: "Required reply text" }),
2748
2831
  to: Type.Optional(Type.String({ description: "Optional sender/session selector when multiple senders or asks are pending; never a message or thread ID" })),
2749
2832
  which: Type.Optional(StringEnum(["oldest", "latest"] as const, { description: "Select the oldest or latest ask when the chosen sender has multiple unresolved asks" })),
2750
2833
  askId: Type.Optional(Type.String({ description: "Stable ask selector returned by intercom_pending" })),
2834
+ contextId: Type.Optional(Type.String({ description: "Exact inbound message selector from its reply hint, including ordinary messages" })),
2835
+ team: Type.Optional(Type.String({ description: "Select an original-message team; cannot override it" })),
2751
2836
  }),
2752
2837
  execute: executeSplitAction("reply"),
2753
2838
  renderCall: renderSplitCall("reply"),
2754
2839
  renderResult: renderSplitResult,
2755
2840
  } as any);
2756
2841
 
2757
- pi.registerTool({
2842
+ registerIntercomTool({
2758
2843
  name: "intercom_team",
2759
2844
  label: "Intercom Team",
2760
- description: "Show your current manager and the live coworkers owned by that manager. No arguments are required.",
2761
- promptSnippet: "Find your manager and managed coworkers without searching the global peer list.",
2762
- promptGuidelines: ["Use intercom_team whenever you need your manager's target or the other coworkers in your managed group."],
2763
- parameters: Type.Object({}),
2764
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
2845
+ description: "Show all named task teams you belong to, or inspect one by name. Falls back to managed-team discovery when no named task team exists.",
2846
+ promptSnippet: "Find your task teams, managers, and teammates.",
2847
+ promptGuidelines: ["Use intercom_team to inspect your teams. A session can belong to multiple teams, with a different manager in each; specify `team` when the task is ambiguous."],
2848
+ parameters: Type.Object({ team: Type.Optional(Type.String({ description: "Named task team to inspect; omit to show all your teams" })) }),
2849
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2765
2850
  try {
2766
2851
  const connectedClient = await ensureConnected("tool");
2767
2852
  syncPresenceIdentity(ctx.sessionManager.getSessionId());
@@ -2770,6 +2855,15 @@ Usage:
2770
2855
  if (selfError) throw new BossTeamScopeError("BOSS_TEAM_METADATA_INVALID", selfError);
2771
2856
  }
2772
2857
  const sessions = await connectedClient.listSessions();
2858
+ if (taskTeamsAvailable()) {
2859
+ const mine = sessionNamedTeams(connectedClient.sessionId!);
2860
+ const selected = params.team === undefined ? mine : mine.filter((entry) => entry.name === params.team);
2861
+ if (params.team !== undefined && selected.length === 0) throw new Error("You are not a member of that task team");
2862
+ if (selected.length > 0) {
2863
+ const teams = selected.map((entry) => namedTeamRoster(entry, connectedClient.sessionId!, sessions));
2864
+ return { content: [{ type: "text", text: teams.map(formatNamedTeamRoster).join("\n\n") }], details: { self: { id: connectedClient.sessionId }, teams } };
2865
+ }
2866
+ } else if (params.team !== undefined) throw new Error("Named task teams are unavailable in Boss mode");
2773
2867
  const team = bossTeamScope.restricted
2774
2868
  ? resolveBossIntercomTeam({ selfId: connectedClient.sessionId, sessions, scope: bossTeamScope })
2775
2869
  : await resolveIntercomTeam({ selfId: connectedClient.sessionId, sessions });
@@ -2789,29 +2883,38 @@ Usage:
2789
2883
  },
2790
2884
  } as any);
2791
2885
 
2792
- pi.registerTool({
2886
+ registerIntercomTool({
2793
2887
  name: "intercom_join",
2888
+ executionMode: "sequential",
2794
2889
  label: "Intercom Join",
2795
- description: "List, join, or create a named intercom team without tmux. Omit name to list joinable teams. Set create=true to create a team and join as manager.",
2796
- promptSnippet: "Join or create a named intercom team so intercom_team works without tmux.",
2890
+ description: "List, join, or create a task team. Joining appends membership without leaving other named teams. The manager can add connected sessions using members; the creator need not have a session name.",
2891
+ promptSnippet: "After user approval, form a task team with the named sessions they want to collaborate with.",
2797
2892
  promptGuidelines: [
2798
- "Use intercom_join with no name to list named teams and TmuxDeck workspaces.",
2799
- "Use intercom_join({ name: \"billing\" }) to join an existing named team or workspace.",
2800
- "Use intercom_join({ name: \"billing\", create: true }) to create a named team and join as manager.",
2893
+ "For independent Pi sessions: when the user asks you to collaborate with named Pi sessions or delegate work, and this task has no approved team, ask once whether to form a team with you and those sessions (for example: 要把我、front、writer 组成一个 team 来负责这个任务吗?). Wait for approval before creating a team or adding peers.",
2894
+ "An explicit user request to create/join a team is approval. Do not ask again for an approved team or in an inbound team-message turn. If the user declines, do not keep prompting or silently create a team. Without a shared team, send an ungrouped direct message by omitting `team`; belonging to an unrelated team must not prevent initial contact. Explicit team messages still require both sessions to be members.",
2895
+ "After approval, discover the intended sessions, then use intercom_join({ name: \"launch\", create: true, members: [\"front\", \"writer\"], work: \"Current task\" }). The tool adds everyone together; do not ask the user to join each terminal manually.",
2896
+ "Reuse the approved team for the same task and append new approved members. Joining another team preserves previous memberships. The same participants doing a different task may need a different team.",
2897
+ "Pass `team` when sending task messages, especially if peers share multiple teams. Replies inherit their original message's team automatically.",
2801
2898
  ],
2802
2899
  parameters: Type.Object({
2803
2900
  name: Type.Optional(Type.String({ description: "Team name to join. Omit to list joinable teams." })),
2804
2901
  create: Type.Optional(Type.Boolean({ description: "Create this named team and join as manager. Requires name." })),
2902
+ members: Type.Optional(Type.Array(Type.String({ description: "Connected session name or stable ID to add; manager-only, and requires name" }))),
2903
+ work: Type.Optional(Type.String({ description: "Task description when creating a team; requires create=true" })),
2805
2904
  }),
2806
2905
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2807
2906
  try {
2808
2907
  const zh = isZhLocale();
2809
- if (params.create) {
2908
+ if (params.create || params.members !== undefined || params.work !== undefined) {
2810
2909
  if (typeof params.name !== "string" || !params.name.trim()) {
2811
- throw new Error(zh ? "创建团队需要名称。" : "Creating a team requires a name.");
2910
+ throw new Error(zh ? "组队需要名称。" : "Forming a team requires a name.");
2812
2911
  }
2813
- const text = await createAndJoinNamedTeam(ctx, params.name);
2814
- return { content: [{ type: "text", text }] };
2912
+ const team = await appendTaskTeam(ctx, params.name, params.create === true, params.members ?? [], params.work);
2913
+ const roster = namedTeamRoster(team, currentSessionId!, await client!.listSessions());
2914
+ const text = params.create
2915
+ ? formatCreateSuccess({ team: team.name, name: currentDisplayName(), zh })
2916
+ : formatNamedJoinSuccess({ team: team.name, name: currentDisplayName(), zh });
2917
+ return { content: [{ type: "text", text: `${text}\n\n${formatNamedTeamRoster(roster)}` }], details: { team: roster } };
2815
2918
  }
2816
2919
  const text = await listOrJoinCircle(ctx, typeof params.name === "string" ? params.name : "");
2817
2920
  return { content: [{ type: "text", text }] };
@@ -2838,7 +2941,7 @@ Usage:
2838
2941
  { name: "intercom_status", label: "Intercom Status", action: "status", description: "Show this session's intercom connection status.", promptSnippet: "Show intercom connection status." },
2839
2942
  ] as const) {
2840
2943
  if (definition.name === "intercom_list" && bossTeamScope.restricted) continue;
2841
- pi.registerTool({
2944
+ registerIntercomTool({
2842
2945
  name: definition.name,
2843
2946
  label: definition.label,
2844
2947
  description: definition.description,
@@ -2856,7 +2959,7 @@ Usage:
2856
2959
  }
2857
2960
 
2858
2961
  if (config.legacyTool) {
2859
- pi.registerTool(legacyIntercomTool);
2962
+ registerIntercomTool(legacyIntercomTool);
2860
2963
  }
2861
2964
 
2862
2965
  async function resolveCurrentContact(ctx: ExtensionContext, generation = runtimeGeneration): Promise<{ target: string; name?: string; id: string; duplicateName: boolean } | undefined> {
@@ -3043,6 +3146,11 @@ Usage:
3043
3146
  const zh = isZhLocale();
3044
3147
  try {
3045
3148
  const membership = classifyMembership();
3149
+ const taskTeams = currentSessionId ? sessionNamedTeams(currentSessionId) : [];
3150
+ if (taskTeams.length > 0 && taskTeamsAvailable()) {
3151
+ notifyIfLive(ctx, `${zh ? "任务团队" : "Task teams"}: ${taskTeams.map((entry) => entry.name).join(", ")}\n${zh ? "你的显示名" : "Display name"}: ${currentDisplayName()}`, "info");
3152
+ return;
3153
+ }
3046
3154
  const namedTeam = runtimeScopeId ? findNamedTeamByScope(runtimeScopeId) : undefined;
3047
3155
  const workspace = namedTeam?.name
3048
3156
  ? undefined