@ctliz/agent-intercom-pi 0.13.0 → 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/README.md CHANGED
@@ -17,9 +17,11 @@
17
17
  | AGY | [`agent-intercom-agy`](https://github.com/ctliz/agent-intercom-agy) |
18
18
  | Fleet lifecycle | [`agent-intercom-orchestrator`](https://github.com/ctliz/agent-intercom-orchestrator) |
19
19
 
20
- ## Pi 0.99 and codemode
20
+ ## Pi 1.0 compatibility and codemode
21
21
 
22
- Version 0.13.0 requires Pi 0.99.1 or newer in the 0.99 release line. It keeps the existing protocol v4 broker, durable queues, acknowledgements, and cross-harness routing unchanged.
22
+ Version 0.14.0 is tested with Pi 1.0.0. The preceding 0.13.0 release was tested with Pi 0.99.1. The development test baseline now uses Pi 1.0.0; host-provided Pi modules remain peer dependencies, not runtime dependencies. The existing protocol v4 broker, durable queues, acknowledgements, and cross-harness routing are unchanged.
23
+
24
+ Pi 1.0 defaults to fullscreen; set `tuiMode` to `"regular"` or launch with `--tui-mode regular` to retain terminal scrollback. Codemode's shorter declarations preserve Intercom's structured `{ ok, text, data }` results and team guidelines. To check tool existence in a script, use `"intercom_send" in tools`, not `typeof tools.intercom_send`, because unknown members now throw. Restart existing Pi processes to use a newly installed Pi version; `/reload` only reloads resources inside the running version.
23
25
 
24
26
  All Intercom tools remain directly callable and are also available through Pi's built-in codemode when active. Scripts receive `{ ok, text, data }` rather than a display string. `data` contains the tool's structured details: delivery flags and message ID, session lists, team roster, pending asks, or connection status. Returned failures set `isError: true` and retain structured data; a deferred ask is successful with `data.pending === true`. Always check `ok` before continuing with dependent actions. Blocked calls, invalid arguments, and thrown exceptions can still reject, so use `try/catch` or `Promise.allSettled()` when appropriate.
25
27
 
@@ -32,20 +34,59 @@ const result = await tools.intercom_send({ to: target.id, message: "Tests passed
32
34
  return { ok: result.ok, accepted: result.data.accepted, delivered: result.data.delivered };
33
35
  ```
34
36
 
35
- Concurrent sends and independent asks are supported; do not create two unresolved asks to the same recipient. Team joins execute sequentially because they change the caller's routing scope. Receiving a delivery acknowledgement means the message is durably queued, not that the recipient finished its task. Busy sessions wait until `ctx.isIdle()`; `agent_settled` updates final idle status after automatic retries and compaction.
37
+ Concurrent sends and independent asks are supported; do not create two unresolved asks to the same recipient. Team joins execute sequentially; named task-team updates are also locked across independently launched Pi processes. Named joins append membership without changing the caller's broker routing scope. Receiving a delivery acknowledgement means the message is durably queued, not that the recipient finished its task. Busy sessions wait until `ctx.isIdle()`; `agent_settled` updates final idle status after automatic retries and compaction.
36
38
 
37
39
  ### Send from a shell or release script
38
40
 
39
41
  The package includes an `intercom-send` executable. Install the alias globally for a shell command, or run it without relying on Pi's private installation path:
40
42
 
41
43
  ```bash
42
- npm exec --yes --package=@ctliz/pi-intercom@0.13.0 -- intercom-send worker 'Tests passed.'
44
+ npm exec --yes --package=@ctliz/pi-intercom@0.14.0 -- intercom-send worker 'Tests passed.'
43
45
  ```
44
46
 
45
47
  It prints one JSON result with `accepted`, `delivered`, `messageId`, and optional failure `code`/`reason`. Exit status is zero only for acknowledged delivery. It inherits the routing scope, but never inherits `PI_INTERCOM_SESSION_ID` or `AGENT_INTERCOM_SESSION_ID`: every invocation registers an independent sender, leaves running Pi sessions intact, and disconnects after sending. It is send-only; use the session tools for reply-tracked asks.
46
48
 
47
49
  When a second runtime claims the same stable session ID, Intercom reports `SESSION_ID_IN_USE`, pauses automatic reconnect, and preserves the original owner. Switch to a different session, or release the duplicate owner and `/reload`. `intercom_status` exposes the conflict as structured data rather than silently treating it as a temporary outage. Updating this adapter does not require restarting a compatible v4 broker.
48
50
 
51
+ ## Pi-local task teams
52
+
53
+ Open Pi normally in separate terminals; no startup team, session name for the
54
+ coordinator, or terminal multiplexer is required. When you ask an agent to work
55
+ with `front` and `writer`, its tool guidelines tell it to ask once whether to form
56
+ a team. After approval (or an explicit request to form a team), it discovers the
57
+ sessions and creates the task group itself:
58
+
59
+ ```typescript
60
+ intercom_join({ name: "launch", create: true, members: ["front", "writer"], work: "Build the product page" })
61
+ intercom_send({ team: "launch", to: "front", message: "Implement the UI." })
62
+ intercom_send({ team: "launch", to: "writer", message: "Write the copy." })
63
+ intercom_team({}) // all your task teams
64
+ intercom_team({ team: "launch" }) // one team's manager and members
65
+ ```
66
+
67
+ Joining another named team appends membership, never replaces it. The manager can
68
+ append approved peers with `members`; any session can join itself. Missing or
69
+ ambiguous targets fail before membership is written. Team updates are persistent
70
+ and serialized across Pi processes. A different task can use a different team
71
+ with the same participants.
72
+
73
+ Every task message carries a public team name, displayed as `[Team: launch]`.
74
+ When two sessions share multiple teams, sends/asks require `team`. Replies inherit
75
+ the exact original message's team: use its receiver-local `contextId` reply hint,
76
+ or an `askId` from `intercom_pending`. Mixed-team batches retain every message's
77
+ label; ambiguous replies fail rather than guessing.
78
+
79
+ This feature changes only Pi's named teams and prompt guidelines. It does not
80
+ change the shared v4 broker or other adapters, does not add a security boundary,
81
+ and does not isolate model history per team. Without a shared team, omitting
82
+ `team` sends an ungrouped direct message even when either session belongs to
83
+ unrelated teams, allowing initial contact before forming a team. Explicit team
84
+ messages still require both sessions to be members; multiple shared teams still
85
+ require selecting `team`. Consent is an agent instruction, not a broker-enforced approval record.
86
+ Managed-team and workspace integration paths are unchanged. All participating Pi
87
+ sessions need the 0.14.0 Pi adapter and `/reload`. This is not a cross-adapter
88
+ protocol rollout.
89
+
49
90
  ## Grok Build and AGY support
50
91
 
51
92
  Grok Build and AGY are supported as first-class protocol peers through two dedicated npm packages:
@@ -122,7 +163,7 @@ Each pi session that has `pi-intercom` loaded and enabled connects to a tiny loc
122
163
  ## Install
123
164
 
124
165
  ```bash
125
- pi install git:github.com/ctliz/agent-intercom-pi@v0.12.2
166
+ pi install npm:@ctliz/pi-intercom@0.14.0
126
167
  ```
127
168
 
128
169
  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.
@@ -321,7 +362,7 @@ If you never set `/name`, Intercom still exposes a runtime-only fallback alias s
321
362
 
322
363
  ### How `intercom_team` chooses a team
323
364
 
324
- `intercom_team({})` has no arguments. It resolves the current group in this order and stops at the first match:
365
+ With Pi-local task teams, `intercom_team({})` first shows all named teams containing this session; `intercom_team({ team: "billing" })` selects one. If there are no named task teams, the existing managed-team resolution below applies. Boss mode keeps its existing resolution and does not accept named task teams.
325
366
 
326
367
  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.
327
368
  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.
@@ -350,7 +391,7 @@ Manifest, live-roster, and standalone teams cannot inspect another session's inb
350
391
 
351
392
  ### Create or join a team without tmux
352
393
 
353
- Named teams live in the local Intercom directory. Creating one generates a private scope, makes this session the manager, and is enough for `intercom_team` to return a live roster. Tmux and TmuxDeck are not required.
394
+ Named teams live in the local Intercom directory. Creating one makes this session the task-team manager and stores explicit member session IDs. Joining appends membership without switching broker scope or replacing previous memberships. Legacy records retain their manager; peers must join again to record their membership. Tmux and TmuxDeck are not required.
354
395
 
355
396
  ```text
356
397
  /intercom-create billing # create a named team and join as manager
@@ -630,11 +671,11 @@ The supervisor can reply with plain JSON or a fenced `json` block. If the reply
630
671
 
631
672
  | Tool | Parameters | Description |
632
673
  |------|------------|-------------|
633
- | `intercom_send` | required `to`, required `message`, optional `attachments` | Fire-and-forget delivery |
634
- | `intercom_ask` | required `to`, required `message`, optional `attachments` | Ask and wait briefly for a reply |
635
- | `intercom_reply` | required `message`, optional `askId`, `to`, `which` | Reply to the active or pending inbound message; `askId` selects an exact unresolved ask, while `to`/`which` remain compatible selectors |
636
- | `intercom_team` | none | Show the current manager and live coworkers owned by that manager |
637
- | `intercom_join` | optional `name`, optional `create` | List, join, or create a named team without tmux |
674
+ | `intercom_send` | required `to`, `message`; optional `team`, `attachments` | Fire-and-forget delivery in a task team |
675
+ | `intercom_ask` | required `to`, `message`; optional `team`, `attachments` | Ask and wait briefly for a reply |
676
+ | `intercom_reply` | required `message`; optional `contextId`, `askId`, `team`, `to`, `which` | Select an exact inbound context and inherit its team |
677
+ | `intercom_team` | optional `team` | Show your named task teams, or fall back to managed-team discovery |
678
+ | `intercom_join` | optional `name`, `create`, `members`, `work` | Append membership; a manager can add connected peers, and `work` describes a newly created task |
638
679
  | `intercom_list` | none | List connected sessions in your scope |
639
680
  | `intercom_pending` | optional `askId`, `session` | List unresolved inbound asks with stable IDs; `askId` retrieves the full untruncated body, and managers may use `session` for an owned coworker |
640
681
  | `intercom_status` | none | Show connection and queue status |
@@ -659,7 +700,7 @@ Only registered in sessions where `pi-subagents` supplied the required child bri
659
700
 
660
701
  ### Tool behavior
661
702
 
662
- **`intercom_team`** reads orchestrator ownership dynamically and returns the current manager plus live same-manager coworkers. After adoption it follows the new manager without restarting the worker; `AGENT_INTERCOM_MANAGER_TARGET` is only a startup fallback.
703
+ **`intercom_team`** first shows your named Pi task teams. Without named memberships it reads orchestrator ownership dynamically and returns the current manager plus live same-manager coworkers. After adoption it follows the new manager without restarting the worker; `AGENT_INTERCOM_MANAGER_TARGET` is only a startup fallback.
663
704
 
664
705
  **`intercom_join`** lists named teams and TmuxDeck workspaces, joins one by name, or creates a named team with `create: true`. Creating a team does not require tmux. Listing never prints the raw scope.
665
706
 
@@ -669,7 +710,7 @@ Only registered in sessions where `pi-subagents` supplied the required child bri
669
710
 
670
711
  **`intercom_ask`** waits up to 30 seconds for a prompt reply, then returns a successful pending result while keeping the request open for a late reply. Different recipients may wait concurrently; the same recipient may have only one unresolved ask. `PI_INTERCOM_ASK_WAIT_MS` changes the blocking window.
671
712
 
672
- **`intercom_reply`** resolves the active or pending inbound context internally. Pass the stable `askId` returned by `intercom_pending` to select one exact ask. Optional `to` and `which: "oldest" | "latest"` remain available for compatibility. None of these values exposes the protocol thread ID.
713
+ **`intercom_reply`** resolves the active or pending inbound context internally and inherits its original team. Use the `contextId` in a team message's reply hint for an exact ordinary message, or pass the stable `askId` returned by `intercom_pending` to select one exact ask. A `team` selector must match the original message, never override it. Optional `to` and `which: "oldest" | "latest"` remain available for compatibility. None of these values exposes the protocol thread ID.
673
714
 
674
715
  The broker refuses a second unresolved `intercom_ask` from one session to the same recipient. Wait for the first answer or use `intercom_send` for a non-blocking follow-up.
675
716
 
package/broker/client.ts CHANGED
@@ -38,6 +38,7 @@ import type {
38
38
 
39
39
  export interface SendOptions {
40
40
  text: string;
41
+ team?: string;
41
42
  attachments?: Attachment[];
42
43
  control?: IntercomCommonControlEnvelope;
43
44
  replyTo?: string;
@@ -125,6 +126,8 @@ function isMessage(value: unknown): value is Message {
125
126
  return false;
126
127
  }
127
128
 
129
+ if (content.team !== undefined && (typeof content.team !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(content.team))) return false;
130
+
128
131
  const attachmentsValid = content.attachments === undefined
129
132
  || (Array.isArray(content.attachments) && content.attachments.every(isAttachment));
130
133
  const controlValid = content.control === undefined || isIntercomCommonControlEnvelope(content.control);
@@ -811,6 +814,7 @@ export class IntercomClient extends EventEmitter {
811
814
  expectsReply: options.expectsReply,
812
815
  content: {
813
816
  text: options.text,
817
+ ...(options.team === undefined ? {} : { team: options.team }),
814
818
  attachments: options.attachments,
815
819
  control: options.control,
816
820
  },
package/index.ts CHANGED
@@ -16,7 +16,8 @@ import { formatMessageTiming } from "./ui/timestamps.ts";
16
16
  import { formatSessionDisplayName, sanitizeDisplayText, sessionOriginLabel, shortestUniqueIdPrefixes } from "./ui/session-identity.ts";
17
17
  import { getAskTimeoutMs, getAskWaitMs, loadConfig, type IntercomConfig } from "./config.ts";
18
18
  import type { SessionInfo, SessionRegistration, Message, Attachment } from "./types.ts";
19
- 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";
20
21
  import { InboundMessageConflictError, PersistentInboundInbox, readPendingAsksSnapshot, type StoredInboundMessage } from "./inbound-inbox.ts";
21
22
  import { PersistentOutboundOutbox } from "./outbound-outbox.ts";
22
23
  import { formatIntercomTeam, resolveBossIntercomTeam, resolveIntercomTeam, resolveManagedInboxSession } from "./team.ts";
@@ -34,7 +35,6 @@ import {
34
35
  } from "./workspace-join.ts";
35
36
  import {
36
37
  buildJoinableCircles,
37
- createNamedTeam,
38
38
  findNamedTeamByScope,
39
39
  formatCreateSuccess,
40
40
  formatJoinableCircleList,
@@ -732,13 +732,14 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
732
732
  const replyWaiters = new Map<string, {
733
733
  from: string;
734
734
  replyTo: string;
735
+ team?: string;
735
736
  resolve: (message: Message) => void;
736
737
  reject: (error: Error) => void;
737
738
  }>();
738
739
  function hasReplyWaiterForTarget(from: string): boolean {
739
740
  return Array.from(replyWaiters.values()).some((waiter) => waiter.from.toLowerCase() === from.toLowerCase());
740
741
  }
741
- 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> {
742
743
  if (hasReplyWaiterForTarget(from)) {
743
744
  return Promise.reject(new Error(`Already waiting for a reply from "${from}"`));
744
745
  }
@@ -763,6 +764,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
763
764
  replyWaiters.set(replyTo, {
764
765
  from,
765
766
  replyTo,
767
+ team,
766
768
  resolve: (message) => {
767
769
  cleanup();
768
770
  resolve(message);
@@ -921,6 +923,9 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
921
923
  }
922
924
  return rejectManagedJoin(classifyMembership(), isZhLocale());
923
925
  }
926
+ function taskTeamsAvailable(): boolean {
927
+ return !rejectCircleChange();
928
+ }
924
929
  async function applyCircleScope(ctx: ExtensionContext, scopeId: string, managerSessionId?: string): Promise<void> {
925
930
  if (managerSessionId) {
926
931
  process.env.AGENT_INTERCOM_MANAGER_TARGET = managerSessionId;
@@ -933,16 +938,22 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
933
938
  await switchRuntimeScope(scopeId);
934
939
  syncPresenceIdentity(currentSessionId ?? ctx.sessionManager.getSessionId());
935
940
  }
936
- async function createAndJoinNamedTeam(ctx: ExtensionContext, name: string): Promise<string> {
941
+ async function appendTaskTeam(ctx: ExtensionContext, name: string, create = false, members: string[] = [], work?: string) {
937
942
  const blocked = rejectCircleChange();
938
943
  if (blocked) throw new Error(blocked);
939
944
  const teamName = parseTeamName(name);
940
- if (!getLiveContext(ctx)) {
941
- 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);
942
952
  }
943
- const managerSessionId = currentSessionId ?? ctx.sessionManager.getSessionId();
944
- const team = createNamedTeam({ name: teamName, managerSessionId });
945
- 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);
946
957
  return formatCreateSuccess({ team: team.name, name: currentDisplayName(), zh: isZhLocale() });
947
958
  }
948
959
  async function listOrJoinCircle(ctx: ExtensionContext, args: string): Promise<string> {
@@ -973,10 +984,12 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
973
984
  ? (zh ? "没有这个编号的团队。" : "No team uses that number.")
974
985
  : (zh ? "无法加入该团队。" : "Could not join that team."));
975
986
  }
987
+ if (selected.kind === "named") {
988
+ await appendTaskTeam(ctx, selected.name);
989
+ return formatNamedJoinSuccess({ team: selected.name, name: currentDisplayName(), zh });
990
+ }
976
991
  await applyCircleScope(ctx, selected.scopeId, selected.managerSessionId);
977
- return selected.kind === "named"
978
- ? formatNamedJoinSuccess({ team: selected.name, name: currentDisplayName(), zh })
979
- : formatJoinSuccess({ workspace: selected.name, name: currentDisplayName(), zh });
992
+ return formatJoinSuccess({ workspace: selected.name, name: currentDisplayName(), zh });
980
993
  }
981
994
 
982
995
  function restoreIntercomSessionId(): void {
@@ -1021,11 +1034,13 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1021
1034
  const attachmentText = stored.message.content.attachments?.length
1022
1035
  ? formatAttachments(stored.message.content.attachments)
1023
1036
  : "";
1024
- const replyCommand = config.replyHint && stored.message.expectsReply
1025
- ? batchSize === 1
1026
- ? `intercom_reply({ message: "..." })`
1027
- : `intercom_reply({ to: "${stored.from.id}", message: "..." })`
1028
- : 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;
1029
1044
  return {
1030
1045
  key: stored.key,
1031
1046
  from: stored.from,
@@ -1039,7 +1054,8 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1039
1054
  const senderDisplay = formatSessionDisplayName(entry.from);
1040
1055
  const prefix = position ? `[${position.index}/${position.total}] ` : "";
1041
1056
  const replyInstruction = entry.replyCommand ? `\n\nTo reply, use: ${entry.replyCommand}` : "";
1042
- 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}`;
1043
1059
  }
1044
1060
  function sendIncomingBatch(entries: InboundMessageEntry[], generation = runtimeGeneration, forceTrigger = false): void {
1045
1061
  if (entries.length === 0) return;
@@ -1210,6 +1226,18 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1210
1226
  });
1211
1227
  return;
1212
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
+ }
1213
1241
  let enqueued;
1214
1242
  try {
1215
1243
  enqueued = inboxAtReceive.enqueue(from, message);
@@ -2226,6 +2254,8 @@ Usage:
2226
2254
  message: Type.Optional(Type.String({
2227
2255
  description: "Message to send (for 'send', 'ask', or 'reply' action)",
2228
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" })),
2229
2259
  attachments: Type.Optional(Type.Array(Type.Object({
2230
2260
  type: StringEnum(["file", "snippet", "context"] as const),
2231
2261
  name: Type.String(),
@@ -2275,7 +2305,7 @@ Usage:
2275
2305
  }
2276
2306
  }
2277
2307
 
2278
- const { action, to, message, attachments, replyTo, askId, session, which } = params;
2308
+ const { action, to, message, attachments, replyTo, askId, session, which, team, contextId } = params;
2279
2309
 
2280
2310
  switch (action) {
2281
2311
  case "list": {
@@ -2337,6 +2367,10 @@ Usage:
2337
2367
  details: { error: true },
2338
2368
  };
2339
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");
2340
2374
  if (!replyTo && config.confirmSend && ctx.hasUI) {
2341
2375
  const attachmentText = attachments?.length ? formatAttachments(attachments) : "";
2342
2376
  const confirmed = await ctx.ui.confirm(
@@ -2352,6 +2386,7 @@ Usage:
2352
2386
  }
2353
2387
  const result = await connectedClient.send(sendTo, {
2354
2388
  text: message,
2389
+ team: messageTeam,
2355
2390
  attachments,
2356
2391
  replyTo,
2357
2392
  });
@@ -2364,7 +2399,7 @@ Usage:
2364
2399
  }
2365
2400
  pi.appendEntry("intercom_sent", {
2366
2401
  to,
2367
- message: { text: message, attachments, replyTo },
2402
+ message: { text: message, attachments, replyTo, team: messageTeam },
2368
2403
  messageId: result.id,
2369
2404
  timestamp: Date.now(),
2370
2405
  });
@@ -2373,8 +2408,8 @@ Usage:
2373
2408
  inboundInbox?.dismissPendingAsk(replyTo, sendTo);
2374
2409
  }
2375
2410
  return {
2376
- content: [{ type: "text", text: `Message sent to ${to}` }],
2377
- details: deliveryResultDetails(result),
2411
+ content: [{ type: "text", text: `Message sent to ${to}${messageTeam ? ` [Team: ${messageTeam}]` : ""}` }],
2412
+ details: deliveryResultDetails(result, messageTeam ? { team: messageTeam } : {}),
2378
2413
  };
2379
2414
  } catch (error) {
2380
2415
  return {
@@ -2406,6 +2441,7 @@ Usage:
2406
2441
  }
2407
2442
  let replyPromise: Promise<Message> | null = null;
2408
2443
  let questionId: string | null = null;
2444
+ let messageTeam: string | undefined;
2409
2445
 
2410
2446
  try {
2411
2447
  const sendTo = await resolveAuthorizedTarget(connectedClient, to);
@@ -2427,12 +2463,17 @@ Usage:
2427
2463
  details: { error: true },
2428
2464
  };
2429
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");
2430
2470
  questionId = randomUUID();
2431
- replyPromise = waitForReply(sendTo, questionId, _signal, () => { void connectedClient.cancelAsk(questionId!); });
2471
+ replyPromise = waitForReply(sendTo, questionId, _signal, () => { void connectedClient.cancelAsk(questionId!); }, messageTeam);
2432
2472
  replyPromise.catch(() => undefined);
2433
2473
  const sendResult = await connectedClient.send(sendTo, {
2434
2474
  messageId: questionId,
2435
2475
  text: message,
2476
+ team: messageTeam,
2436
2477
  attachments,
2437
2478
  replyTo,
2438
2479
  expectsReply: true,
@@ -2455,7 +2496,7 @@ Usage:
2455
2496
  }
2456
2497
  pi.appendEntry("intercom_sent", {
2457
2498
  to,
2458
- message: { text: message, attachments, replyTo },
2499
+ message: { text: message, attachments, replyTo, team: messageTeam },
2459
2500
  messageId: sendResult.id,
2460
2501
  timestamp: Date.now(),
2461
2502
  });
@@ -2466,17 +2507,18 @@ Usage:
2466
2507
  : "";
2467
2508
  pi.appendEntry("intercom_received", {
2468
2509
  from: to,
2469
- message: { text: replyText, attachments: replyMessage.content.attachments },
2510
+ message: { text: replyText, team: replyMessage.content.team, attachments: replyMessage.content.attachments },
2470
2511
  messageId: replyMessage.id,
2471
2512
  timestamp: replyMessage.timestamp,
2472
2513
  });
2473
2514
  return {
2474
- content: [{ type: "text", text: `**Reply from ${to}:**\n${replyText}${replyAttachments}` }],
2475
- details: {},
2515
+ content: [{ type: "text", text: `**${messageTeam ? `[Team: ${messageTeam}] ` : ""}Reply from ${to}:**\n${replyText}${replyAttachments}` }],
2516
+ details: messageTeam ? { team: messageTeam } : {},
2476
2517
  };
2477
2518
  } catch (error) {
2478
2519
  if (error instanceof AskWaitElapsedError) {
2479
- 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 } : {}) } };
2480
2522
  }
2481
2523
  if (questionId) rejectReplyWaiter(questionId, toError(error));
2482
2524
  if (replyPromise) {
@@ -2508,7 +2550,9 @@ Usage:
2508
2550
  if ("code" in requested) throw new BossTeamScopeError(requested.code, requested.error);
2509
2551
  exactReplyTarget = requested.targetId;
2510
2552
  }
2511
- 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);
2512
2556
  if (bossTeamScope.present) {
2513
2557
  const resolution = resolveBossLiveTarget(bossTeamScope, target.from.id, await connectedClient.listSessions(), connectedClient.sessionId);
2514
2558
  if ("code" in resolution) throw new BossTeamScopeError(resolution.code, resolution.error);
@@ -2522,6 +2566,7 @@ Usage:
2522
2566
  const threadedReplyTo = target.message.expectsReply ? target.message.id : undefined;
2523
2567
  const result = await connectedClient.send(target.from.id, {
2524
2568
  text: message,
2569
+ team: replyTeam,
2525
2570
  ...(threadedReplyTo ? { replyTo: threadedReplyTo } : {}),
2526
2571
  });
2527
2572
  if (!result.delivered) {
@@ -2539,17 +2584,17 @@ Usage:
2539
2584
  replyTracker.markReplied(threadedReplyTo, target.from.id);
2540
2585
  inboundInbox?.dismissPendingAsk(threadedReplyTo, target.from.id);
2541
2586
  } else {
2542
- replyTracker.dismissOrdinarySender(target.from.id);
2587
+ replyTracker.dismissOrdinarySender(target.from.id, target.message.id);
2543
2588
  }
2544
2589
  pi.appendEntry("intercom_sent", {
2545
2590
  to: bossTeamScope.present ? target.from.id : target.from.name || target.from.id,
2546
- message: { text: message, ...(threadedReplyTo ? { replyTo: threadedReplyTo } : {}) },
2591
+ message: { text: message, team: replyTeam, ...(threadedReplyTo ? { replyTo: threadedReplyTo } : {}) },
2547
2592
  messageId: result.id,
2548
2593
  timestamp: Date.now(),
2549
2594
  });
2550
2595
  return {
2551
- content: [{ type: "text", text: `Reply sent to ${bossTeamScope.present ? target.from.id : target.from.name || target.from.id}` }],
2552
- 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 } : {}) }),
2553
2598
  };
2554
2599
  } catch (error) {
2555
2600
  return {
@@ -2621,7 +2666,7 @@ Usage:
2621
2666
  : "";
2622
2667
  const sender = bossTeamScope.present ? ask.from.id : ask.from.name || ask.from.id;
2623
2668
  return {
2624
- 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}` }],
2625
2670
  details: {
2626
2671
  inboxSessionId,
2627
2672
  ask: {
@@ -2631,6 +2676,7 @@ Usage:
2631
2676
  ...(ask.deferredAt ? { deferredAt: ask.deferredAt } : {}),
2632
2677
  preview: ask.preview,
2633
2678
  body: ask.message.content.text,
2679
+ ...(ask.message.content.team ? { team: ask.message.content.team } : {}),
2634
2680
  attachments: ask.message.content.attachments ?? [],
2635
2681
  },
2636
2682
  },
@@ -2640,7 +2686,7 @@ Usage:
2640
2686
  const lines = asks.map((ask) => {
2641
2687
  const state = ask.deferredAt ? " · async" : "";
2642
2688
  const sender = bossTeamScope.present ? ask.from.id : ask.from.name || ask.from.id;
2643
- 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}`;
2644
2690
  });
2645
2691
  return {
2646
2692
  content: [{ type: "text", text: `**Pending asks:**\n${lines.join("\n")}` }],
@@ -2652,6 +2698,7 @@ Usage:
2652
2698
  receivedAt: ask.receivedAt,
2653
2699
  ...(ask.deferredAt ? { deferredAt: ask.deferredAt } : {}),
2654
2700
  preview: ask.preview,
2701
+ ...(ask.message.content.team ? { team: ask.message.content.team } : {}),
2655
2702
  })),
2656
2703
  },
2657
2704
  };
@@ -2747,6 +2794,7 @@ Usage:
2747
2794
  promptGuidelines: ["Use intercom_send for progress updates, notifications, and other messages that do not require a conversational reply."],
2748
2795
  parameters: Type.Object({
2749
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." })),
2750
2798
  message: Type.String({ description: "Required message to send" }),
2751
2799
  attachments: attachmentParameters,
2752
2800
  }),
@@ -2763,6 +2811,7 @@ Usage:
2763
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."],
2764
2812
  parameters: Type.Object({
2765
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." })),
2766
2815
  message: Type.String({ description: "Required question to ask" }),
2767
2816
  attachments: attachmentParameters,
2768
2817
  }),
@@ -2776,12 +2825,14 @@ Usage:
2776
2825
  label: "Intercom Reply",
2777
2826
  description: "Reply to an inbound intercom message or ask. Exact protocol threading is resolved internally.",
2778
2827
  promptSnippet: "Reply to the active or pending inbound intercom message.",
2779
- 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."],
2780
2829
  parameters: Type.Object({
2781
2830
  message: Type.String({ description: "Required reply text" }),
2782
2831
  to: Type.Optional(Type.String({ description: "Optional sender/session selector when multiple senders or asks are pending; never a message or thread ID" })),
2783
2832
  which: Type.Optional(StringEnum(["oldest", "latest"] as const, { description: "Select the oldest or latest ask when the chosen sender has multiple unresolved asks" })),
2784
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" })),
2785
2836
  }),
2786
2837
  execute: executeSplitAction("reply"),
2787
2838
  renderCall: renderSplitCall("reply"),
@@ -2791,11 +2842,11 @@ Usage:
2791
2842
  registerIntercomTool({
2792
2843
  name: "intercom_team",
2793
2844
  label: "Intercom Team",
2794
- description: "Show your current manager and the live coworkers owned by that manager. No arguments are required.",
2795
- promptSnippet: "Find your manager and managed coworkers without searching the global peer list.",
2796
- promptGuidelines: ["Use intercom_team whenever you need your manager's target or the other coworkers in your managed group."],
2797
- parameters: Type.Object({}),
2798
- 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) {
2799
2850
  try {
2800
2851
  const connectedClient = await ensureConnected("tool");
2801
2852
  syncPresenceIdentity(ctx.sessionManager.getSessionId());
@@ -2804,6 +2855,15 @@ Usage:
2804
2855
  if (selfError) throw new BossTeamScopeError("BOSS_TEAM_METADATA_INVALID", selfError);
2805
2856
  }
2806
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");
2807
2867
  const team = bossTeamScope.restricted
2808
2868
  ? resolveBossIntercomTeam({ selfId: connectedClient.sessionId, sessions, scope: bossTeamScope })
2809
2869
  : await resolveIntercomTeam({ selfId: connectedClient.sessionId, sessions });
@@ -2827,26 +2887,34 @@ Usage:
2827
2887
  name: "intercom_join",
2828
2888
  executionMode: "sequential",
2829
2889
  label: "Intercom Join",
2830
- 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.",
2831
- 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.",
2832
2892
  promptGuidelines: [
2833
- "Use intercom_join with no name to list named teams and TmuxDeck workspaces.",
2834
- "Use intercom_join({ name: \"billing\" }) to join an existing named team or workspace.",
2835
- "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.",
2836
2898
  ],
2837
2899
  parameters: Type.Object({
2838
2900
  name: Type.Optional(Type.String({ description: "Team name to join. Omit to list joinable teams." })),
2839
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" })),
2840
2904
  }),
2841
2905
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2842
2906
  try {
2843
2907
  const zh = isZhLocale();
2844
- if (params.create) {
2908
+ if (params.create || params.members !== undefined || params.work !== undefined) {
2845
2909
  if (typeof params.name !== "string" || !params.name.trim()) {
2846
- throw new Error(zh ? "创建团队需要名称。" : "Creating a team requires a name.");
2910
+ throw new Error(zh ? "组队需要名称。" : "Forming a team requires a name.");
2847
2911
  }
2848
- const text = await createAndJoinNamedTeam(ctx, params.name);
2849
- 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 } };
2850
2918
  }
2851
2919
  const text = await listOrJoinCircle(ctx, typeof params.name === "string" ? params.name : "");
2852
2920
  return { content: [{ type: "text", text }] };
@@ -3078,6 +3146,11 @@ Usage:
3078
3146
  const zh = isZhLocale();
3079
3147
  try {
3080
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
+ }
3081
3154
  const namedTeam = runtimeScopeId ? findNamedTeamByScope(runtimeScopeId) : undefined;
3082
3155
  const workspace = namedTeam?.name
3083
3156
  ? undefined
@@ -0,0 +1,124 @@
1
+ import { mkdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { ensureIntercomRuntimeDir, getAgentDirPath, getIntercomDirPath } from "./broker/paths.ts";
5
+ import { writeDurableJson } from "./durable-json.ts";
6
+ import { generateNamedTeamScope, listNamedTeams, NAMED_TEAMS_FILE, NAMED_TEAMS_VERSION, parseTeamName, type NamedTeam } from "./named-teams.ts";
7
+ import type { SessionInfo } from "./types.ts";
8
+
9
+ export function namedTeamMemberIds(team: NamedTeam): string[] {
10
+ return team.memberSessionIds ?? [team.managerSessionId];
11
+ }
12
+
13
+ export function sessionNamedTeams(sessionId: string, agentDir?: string): NamedTeam[] {
14
+ return listNamedTeams(agentDir).filter((team) => namedTeamMemberIds(team).includes(sessionId));
15
+ }
16
+
17
+ /** Serialize the complete read-modify-write across independently launched Pi processes. */
18
+ export async function appendNamedTeamMembership(input: {
19
+ name: string;
20
+ selfId: string;
21
+ members?: string[];
22
+ create?: boolean;
23
+ work?: string;
24
+ agentDir?: string;
25
+ }): Promise<NamedTeam> {
26
+ const name = parseTeamName(input.name);
27
+ const memberIds = [...new Set([input.selfId, ...(input.members ?? [])])];
28
+ if (memberIds.some((id) => !id || id.trim() !== id || /[\u0000-\u001f\u007f]/.test(id))) {
29
+ throw new Error("Invalid team member session ID");
30
+ }
31
+ if (input.work !== undefined && (!input.work.trim() || input.work.length > 2000)) {
32
+ throw new Error("Work must be a non-empty task description (max 2000 characters)");
33
+ }
34
+ const dir = getIntercomDirPath(input.agentDir ?? getAgentDirPath());
35
+ ensureIntercomRuntimeDir(dir);
36
+ const lock = join(dir, "named-teams.lock");
37
+ const deadline = Date.now() + 5000;
38
+ for (;;) {
39
+ try {
40
+ mkdirSync(lock, { mode: 0o700 });
41
+ break;
42
+ } catch (error) {
43
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
44
+ if (Date.now() >= deadline) throw new Error("Team registry is busy; retry. If a writer crashed, remove the stale named-teams.lock directory after verifying no writer is active.");
45
+ await delay(25);
46
+ }
47
+ }
48
+ try {
49
+ const teams = listNamedTeams(input.agentDir);
50
+ let team = teams.find((entry) => entry.name === name);
51
+ if (input.create) {
52
+ if (team) throw new Error(`A named team called ${name} already exists; join it instead.`);
53
+ team = { name, scopeId: generateNamedTeamScope(teams.map((entry) => entry.scopeId)), managerSessionId: input.selfId, createdAt: Date.now() };
54
+ teams.push(team);
55
+ }
56
+ if (!team) throw new Error(`Team "${name}" does not exist; create it explicitly.`);
57
+ if (memberIds.some((id) => id !== input.selfId) && team.managerSessionId !== input.selfId) {
58
+ throw new Error(`Only the manager of team "${name}" may add other sessions`);
59
+ }
60
+ if (input.work !== undefined && !input.create) {
61
+ throw new Error("Work is set when creating a team; use a new team for a different task");
62
+ }
63
+ const updated: NamedTeam = {
64
+ ...team,
65
+ memberSessionIds: [...new Set([...namedTeamMemberIds(team), ...memberIds])],
66
+ ...(input.work === undefined ? {} : { work: input.work.trim() }),
67
+ };
68
+ writeDurableJson(join(dir, NAMED_TEAMS_FILE), {
69
+ version: NAMED_TEAMS_VERSION,
70
+ teams: teams.map((entry) => entry.name === name ? updated : entry),
71
+ });
72
+ return updated;
73
+ } finally {
74
+ rmSync(lock, { recursive: true });
75
+ }
76
+ }
77
+
78
+ export function requireNamedTeamMembers(name: string, selfId: string, peerId: string, agentDir?: string): NamedTeam {
79
+ const team = listNamedTeams(agentDir).find((entry) => entry.name === name);
80
+ if (!team) throw new Error(`Unknown team "${name}"`);
81
+ const members = namedTeamMemberIds(team);
82
+ if (!members.includes(selfId) || !members.includes(peerId)) {
83
+ throw new Error(`Both sessions must belong to team "${name}" before messaging`);
84
+ }
85
+ return team;
86
+ }
87
+
88
+ export function resolveNamedMessageTeam(selfId: string, peerId: string, requested?: string, agentDir?: string): string | undefined {
89
+ if (requested !== undefined) return requireNamedTeamMembers(requested, selfId, peerId, agentDir).name;
90
+ const mine = sessionNamedTeams(selfId, agentDir);
91
+ const shared = mine.filter((team) => namedTeamMemberIds(team).includes(peerId));
92
+ if (shared.length === 1) return shared[0]!.name;
93
+ if (shared.length > 1) throw new Error("Multiple shared teams; specify `team` for this task");
94
+ // No shared team means an ungrouped contact, even when either session belongs
95
+ // to unrelated teams. Do not silently create a team or add the recipient.
96
+ return undefined;
97
+ }
98
+
99
+ export function namedTeamRoster(team: NamedTeam, selfId: string, sessions: SessionInfo[]) {
100
+ return {
101
+ name: team.name,
102
+ ...(team.work ? { work: team.work } : {}),
103
+ self: { id: selfId, isManager: selfId === team.managerSessionId },
104
+ manager: { target: team.managerSessionId, connected: sessions.some((entry) => entry.id === team.managerSessionId) },
105
+ members: namedTeamMemberIds(team).map((id) => ({
106
+ id,
107
+ target: id,
108
+ name: sessions.find((entry) => entry.id === id)?.name,
109
+ role: id === team.managerSessionId ? "manager" : "member",
110
+ connected: sessions.some((entry) => entry.id === id),
111
+ })),
112
+ };
113
+ }
114
+
115
+ export function formatNamedTeamRoster(team: ReturnType<typeof namedTeamRoster>): string {
116
+ return [
117
+ `Team: ${team.name}`,
118
+ ...(team.work ? [`Work: ${team.work}`] : []),
119
+ `You: ${team.self.id}${team.self.isManager ? " [manager]" : ""}`,
120
+ `Manager: ${team.manager.target}${team.manager.connected ? "" : " [offline]"}`,
121
+ "Members:",
122
+ ...team.members.map((member) => `- ${member.name || member.id} (${member.id}) [${member.role}]${member.connected ? "" : " [offline]"}`),
123
+ ].join("\n");
124
+ }
package/named-teams.ts CHANGED
@@ -14,6 +14,9 @@ export interface NamedTeam {
14
14
  scopeId: string;
15
15
  managerSessionId: string;
16
16
  createdAt: number;
17
+ /** Pi task-team membership; absent in legacy single-scope teams. */
18
+ memberSessionIds?: string[];
19
+ work?: string;
17
20
  }
18
21
 
19
22
  export type JoinableCircleKind = "named" | "tmuxdeck";
@@ -57,11 +60,20 @@ function parseStoredTeam(value: unknown): NamedTeam | undefined {
57
60
  if (typeof value.createdAt !== "number" || !Number.isSafeInteger(value.createdAt) || value.createdAt <= 0) {
58
61
  return undefined;
59
62
  }
63
+ if (value.memberSessionIds !== undefined && (
64
+ !Array.isArray(value.memberSessionIds)
65
+ || !value.memberSessionIds.every((id) => typeof id === "string" && id.trim() === id && id.length > 0 && !/[\u0000-\u001f\u007f]/.test(id))
66
+ || new Set(value.memberSessionIds).size !== value.memberSessionIds.length
67
+ || !value.memberSessionIds.includes(value.managerSessionId)
68
+ )) return undefined;
69
+ if (value.work !== undefined && (typeof value.work !== "string" || !value.work.trim() || value.work.length > 2000)) return undefined;
60
70
  return {
61
71
  name: value.name,
62
72
  scopeId: value.scopeId,
63
73
  managerSessionId: value.managerSessionId,
64
74
  createdAt: value.createdAt,
75
+ ...(value.memberSessionIds === undefined ? {} : { memberSessionIds: value.memberSessionIds as string[] }),
76
+ ...(value.work === undefined ? {} : { work: value.work as string }),
65
77
  };
66
78
  }
67
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctliz/agent-intercom-pi",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Pi coding-agent intercom for local messaging with Codex, Claude Code, OpenCode, Grok Build, and AGY agents.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -58,14 +58,20 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@ctliz/agent-intercom-core": "0.2.0",
61
- "@earendil-works/pi-ai": "^0.99.1",
62
- "@earendil-works/pi-coding-agent": "^0.99.1",
63
- "@earendil-works/pi-tui": "^0.99.1",
64
- "tsx": "^4.20.0",
61
+ "tsx": "^4.20.0"
62
+ },
63
+ "peerDependencies": {
64
+ "@earendil-works/pi-ai": "*",
65
+ "@earendil-works/pi-coding-agent": "*",
66
+ "@earendil-works/pi-tui": "*",
65
67
  "typebox": "*"
66
68
  },
67
69
  "devDependencies": {
70
+ "@earendil-works/pi-ai": "^1.0.0",
71
+ "@earendil-works/pi-coding-agent": "^1.0.0",
72
+ "@earendil-works/pi-tui": "^1.0.0",
68
73
  "@types/node": "^22.15.0",
74
+ "typebox": "*",
69
75
  "typescript": "^5.9.3"
70
76
  },
71
77
  "publishConfig": {
package/reply-tracker.ts CHANGED
@@ -21,6 +21,10 @@ export function pendingAskId(fromSessionId: string, messageId: string): string {
21
21
  return `ask-${digest}`;
22
22
  }
23
23
 
24
+ export function replyContextId(fromSessionId: string, messageId: string): string {
25
+ return pendingAskId(fromSessionId, messageId).replace(/^ask-/, "ctx-");
26
+ }
27
+
24
28
  function matchesPendingSender(context: IntercomContext, to: string): boolean {
25
29
  if (context.from.id === to) {
26
30
  return true;
@@ -91,8 +95,23 @@ export class ReplyTracker {
91
95
  this.currentTurnContexts = [];
92
96
  }
93
97
 
94
- resolveReplyTarget(options: { to?: string; replyTo?: string; askId?: string; which?: ReplyWhich }, now = Date.now()): IntercomContext {
98
+ resolveReplyTarget(options: { to?: string; replyTo?: string; askId?: string; which?: ReplyWhich; team?: string; contextId?: string }, now = Date.now()): IntercomContext {
95
99
  this.pruneExpired(now);
100
+ const checkTeam = (context: IntercomContext): IntercomContext => {
101
+ if (options.team !== undefined && context.message.content.team !== options.team) {
102
+ throw new Error("Reply team must match the original message; it cannot be changed");
103
+ }
104
+ return context;
105
+ };
106
+ if (options.contextId) {
107
+ const match = [...this.currentTurnContexts, ...this.pendingAsks.values()].find((context) =>
108
+ replyContextId(context.from.id, context.message.id) === options.contextId
109
+ );
110
+ if (!match) throw new Error(`No active message with context ID "${options.contextId}"`);
111
+ if (options.to && !matchesPendingSender(match, options.to)) throw new Error("Reply context is not from the selected sender");
112
+ if (options.askId && pendingAskId(match.from.id, match.message.id) !== options.askId) throw new Error("Reply selectors refer to different messages");
113
+ return checkTeam(match);
114
+ }
96
115
 
97
116
  if (options.askId) {
98
117
  const match = Array.from(this.pendingAsks.values()).find((context) =>
@@ -104,7 +123,7 @@ export class ReplyTracker {
104
123
  if (options.to && !matchesPendingSender(match, options.to)) {
105
124
  throw new Error(`Pending ask "${options.askId}" is not from "${options.to}"`);
106
125
  }
107
- return match;
126
+ return checkTeam(match);
108
127
  }
109
128
 
110
129
  if (options.replyTo) {
@@ -119,13 +138,18 @@ export class ReplyTracker {
119
138
  if (matches.length > 1) {
120
139
  throw new Error(`Multiple pending asks use message ID "${options.replyTo}" — specify \`to\``);
121
140
  }
122
- return matches[0]!;
141
+ return checkTeam(matches[0]!);
123
142
  }
124
143
 
125
- if (this.currentTurnContexts.length > 0) {
144
+ const inTeam = (context: IntercomContext) => options.team === undefined || context.message.content.team === options.team;
145
+ const currentTurnContexts = this.currentTurnContexts.filter(inTeam);
146
+ if (currentTurnContexts.length > 0) {
126
147
  const turnMatches = options.to
127
- ? this.currentTurnContexts.filter((context) => matchesPendingSender(context, options.to!))
128
- : this.currentTurnContexts;
148
+ ? currentTurnContexts.filter((context) => matchesPendingSender(context, options.to!))
149
+ : currentTurnContexts;
150
+ if (new Set(turnMatches.map((context) => context.message.content.team)).size > 1) {
151
+ throw new Error("Messages from multiple teams are active — specify `contextId`, `askId`, or `team`");
152
+ }
129
153
  const replyableMatches = turnMatches.filter((context) => context.message.expectsReply);
130
154
  if (replyableMatches.length === 1) {
131
155
  return replyableMatches[0]!;
@@ -146,14 +170,13 @@ export class ReplyTracker {
146
170
  }
147
171
  }
148
172
 
149
- const pending = Array.from(this.pendingAsks.values());
150
- if (pending.length === 1) {
151
- return pending[0]!;
152
- }
153
-
173
+ const pending = Array.from(this.pendingAsks.values()).filter(inTeam);
154
174
  const matches = options.to
155
175
  ? pending.filter((context) => matchesPendingSender(context, options.to!))
156
176
  : pending;
177
+ if (new Set(matches.map((context) => context.message.content.team)).size > 1) {
178
+ throw new Error("Pending asks belong to multiple teams — specify `askId` or `team`");
179
+ }
157
180
  if (matches.length === 1) return matches[0]!;
158
181
  if (matches.length > 1) {
159
182
  if (!options.to && distinctSenders(matches) > 1) {
@@ -174,9 +197,9 @@ export class ReplyTracker {
174
197
  this.dismissPendingAsk(replyTo, fromSessionId);
175
198
  }
176
199
 
177
- dismissOrdinarySender(fromSessionId: string): void {
200
+ dismissOrdinarySender(fromSessionId: string, messageId?: string): void {
178
201
  this.currentTurnContexts = this.currentTurnContexts.filter((context) =>
179
- context.message.expectsReply || context.from.id !== fromSessionId
202
+ context.message.expectsReply || context.from.id !== fromSessionId || (messageId !== undefined && context.message.id !== messageId)
180
203
  );
181
204
  }
182
205
 
@@ -2,8 +2,8 @@
2
2
  name: pi-intercom
3
3
  description: |
4
4
  Streamline session-to-session coordination with pi-intercom. Send messages,
5
- delegate tasks, and coordinate work across multiple pi sessions on the same
6
- machine. Use for planner-worker workflows, cross-session context sharing,
5
+ delegate tasks, and form task teams after user approval across multiple Pi
6
+ sessions on the same machine. Use for planner-worker workflows, cross-session context sharing,
7
7
  and real-time collaboration between sessions.
8
8
  ---
9
9
 
@@ -24,6 +24,60 @@ This skill covers how to handle those orchestrator-side escalations.
24
24
  - **Clarification loops**: Worker asks questions, planner answers, work continues
25
25
  - **Multi-session workflows**: Coordinate between specialized sessions (frontend/backend, research/implementation)
26
26
 
27
+ ## Task Teams: Ask Once, Then Organize (Pi 0.14+)
28
+
29
+ Independently opened Pi sessions do not form a team merely by sharing a directory.
30
+ When the user asks you to collaborate with named sessions (for example `front`
31
+ and `writer`) or delegate work, and this task has no approved team, ask once:
32
+
33
+ > 要把我、front、writer 组成一个 team 来负责这个任务吗?
34
+
35
+ Wait for approval before creating a team or adding peers. An explicit instruction
36
+ to create/join a team is already approval. Do not ask again for the same approved
37
+ team, and do not prompt the user when replying to an inbound team message. If the
38
+ user declines, do not repeatedly ask or silently create a team. Without a shared
39
+ team, omit `team` to send an ungrouped direct message, even if either session
40
+ belongs to unrelated teams. This allows initial contact before forming a team.
41
+ Explicit team messages still require both sessions to be members; multiple
42
+ shared teams still require selecting the intended task team.
43
+
44
+ After approval, discover the named sessions and form the team in one operation:
45
+
46
+ ```typescript
47
+ intercom_list({})
48
+ intercom_join({
49
+ name: "launch",
50
+ create: true,
51
+ members: ["front", "writer"],
52
+ work: "Build the product page; front owns UI, writer owns copy",
53
+ })
54
+ intercom_send({ team: "launch", to: "front", message: "Implement the product page UI." })
55
+ intercom_send({ team: "launch", to: "writer", message: "Write the product page copy." })
56
+ ```
57
+
58
+ The creator is the manager of this task team and does not need a `/name`.
59
+ Members are resolved to stable session IDs; no user needs to join each terminal
60
+ manually. Only the manager can add other sessions with `members`; a session can
61
+ join itself with `intercom_join({ name: "launch" })`. Joining appends membership
62
+ without leaving other named teams or changing their managers. The approved
63
+ manager can append a new approved peer with
64
+ `intercom_join({ name: "launch", members: ["reviewer"] })`.
65
+
66
+ Reuse the team for this task, not for every message. A different task may need a
67
+ different team even with identical participants. `intercom_team({})` shows all
68
+ your named teams; `intercom_team({ team: "launch" })` inspects one.
69
+
70
+ Messages carry their team separately from their text. Read each `[Team: ...]`
71
+ label independently, including mixed-team batches. Use the exact `contextId`
72
+ reply hint to answer ordinary messages, or `askId` from `intercom_pending` for
73
+ asks. `intercom_reply` inherits the original message's team; never choose a reply
74
+ team using a mutable current-team setting. When peers share multiple teams,
75
+ explicitly pass `team` to sends and asks. Ambiguous replies fail rather than guess.
76
+
77
+ This is Pi-local task grouping, not a broker security boundary or separate model
78
+ history for each team. Other adapters and managed-team integrations are unchanged;
79
+ all participating Pi sessions must load adapter 0.14.0 or newer.
80
+
27
81
  ## Codemode (Pi 0.99.1+)
28
82
 
29
83
  Active Intercom tools can be called from codemode as `await tools.intercom_send({...})`, `await tools.intercom_team({})`, and so on. Every result is `{ ok, text, data }`: check `ok`, then read structured delivery flags, session lists, team roster, or pending asks from `data`. Returned errors retain this object; invalid arguments, blocked calls, and thrown exceptions can still reject. Use `try/catch` or `Promise.allSettled()` for independent operations.
@@ -91,12 +145,11 @@ Do not add artificial sleeps to form a batch. Each original message keeps its se
91
145
 
92
146
  ### Pattern 3: Find Your Manager or Team
93
147
 
94
- If no team exists yet, create one and have the other session join. This does not require tmux:
148
+ For independent Pi sessions, ask for approval first, then create a task team and add the connected peer:
95
149
 
96
150
  ```typescript
97
- intercom_join({ name: "billing", create: true }) // manager
98
- intercom_join({ name: "billing" }) // other session
99
- intercom_team({})
151
+ intercom_join({ name: "billing", create: true, members: ["worker"], work: "Implement billing" })
152
+ intercom_team({ team: "billing" })
100
153
  ```
101
154
 
102
155
  `/intercom-create billing` and `/intercom-join billing` do the same from the command line. `intercom_join({})` lists named teams and TmuxDeck workspaces without printing raw scopes.
@@ -135,7 +188,7 @@ intercom_pending({ askId: "ask-..." }) // Retrieve the full untruncated body whe
135
188
  intercom_reply({ askId: "ask-...", message: "Use exponential backoff starting at 100ms." })
136
189
  ```
137
190
 
138
- `intercom_reply` preserves exact threading internally. `intercom_pending` returns stable receiver-local ask IDs and short previews; models never see or construct the protocol message ID. Managers can pass `session` with an owned coworker target from `intercom_team` to inspect that coworker's pending inbox.
191
+ `intercom_reply` preserves exact threading and the original team internally. Team-message reply hints provide an exact receiver-local `contextId`, including for ordinary messages. `intercom_pending` returns stable receiver-local ask IDs, team labels, and short previews; models never see or construct the protocol message ID. Managers can pass `session` with an owned coworker target from `intercom_team` to inspect that coworker's pending inbox.
139
192
 
140
193
  ### Pattern 5: Broadcast to Multiple Workers
141
194
 
package/types.ts CHANGED
@@ -36,6 +36,8 @@ export interface Message {
36
36
  expectsReply?: boolean;
37
37
  content: {
38
38
  text: string;
39
+ /** Public, immutable task-team name; not a broker registration scope. */
40
+ team?: string;
39
41
  attachments?: Attachment[];
40
42
  control?: IntercomCommonControlEnvelope;
41
43
  };
@@ -47,7 +47,8 @@ export class InlineMessageComponent implements Component {
47
47
  }
48
48
  const bodyWidth = Math.max(1, width - 2);
49
49
 
50
- const header = ` 📨 From: ${senderName} (${senderCwd}) `;
50
+ const teamLabel = this.message.content.team ? `[Team: ${sanitizeDisplayText(this.message.content.team)}] ` : "";
51
+ const header = ` ${teamLabel}📨 From: ${senderName} (${senderCwd}) `;
51
52
  const headerText = truncateToWidth(this.collapsed ? `${header} Ctrl+O expands ` : header, bodyWidth, "");
52
53
  const headerPadding = Math.max(0, bodyWidth - visibleWidth(headerText));
53
54
  lines.push(this.theme.fg("accent", `╭${headerText}${borderChar.repeat(headerPadding)}╮`));