@ctliz/agent-intercom-pi 0.13.0 → 0.14.1

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.1 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.1 is tested with Pi 1.1.0. Version 0.14.0 was tested with Pi 1.0.0, and 0.13.0 with Pi 0.99.1. The development test baseline now uses Pi 1.1.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
 
@@ -748,7 +789,7 @@ Custom broker commands are trusted local configuration: anyone who can edit this
748
789
  }
749
790
  ```
750
791
 
751
- Pi-intercom publishes live session status automatically. Sessions register as `idle`, switch to `thinking` while the agent is running, show `tool:<name>` during tool execution, and return to `idle` on agent completion. If `status` is set in config, it is appended as context instead of replacing the lifecycle status.
792
+ Pi-intercom publishes live session status automatically. Sessions register as `idle`, switch to `thinking` while the agent is running, and show `tool:<name>` during tool execution. They remain busy through automatic retries, compaction, and queued continuations until `agent_settled`. Normal settlement returns to `idle`; on Pi 1.1.0 or newer, `aborted: true` shows `cancelled` until the next run or session start. Older hosts without the `aborted` field continue to show `idle` after settlement. If `status` is set in config, it is appended as context instead of replacing the lifecycle status.
752
793
 
753
794
  By default, runtime state and config live under `~/.pi/agent/intercom`. If Pi is launched with `PI_CODING_AGENT_DIR`, pi-intercom uses `$PI_CODING_AGENT_DIR/intercom` instead, including `config.json`, broker PID/lock files, sockets, durable inboxes/outboxes, ask state, and launcher state. `PI_INTERCOM_ASK_WAIT_MS` controls the foreground ask wait (30 seconds by default); `PI_INTERCOM_ASK_TIMEOUT_MS` controls how long a deferred ask remains replyable (10 minutes by default).
754
795
 
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,
@@ -721,6 +721,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
721
721
  let runtimeStarted = false;
722
722
  let runtimeGeneration = 0;
723
723
  let agentRunning = false;
724
+ let lastRunAborted = false;
724
725
  const activeTools = new Map<string, string>();
725
726
  const replyTracker = new ReplyTracker();
726
727
  const registeredControlTypes = new Set<string>();
@@ -732,13 +733,14 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
732
733
  const replyWaiters = new Map<string, {
733
734
  from: string;
734
735
  replyTo: string;
736
+ team?: string;
735
737
  resolve: (message: Message) => void;
736
738
  reject: (error: Error) => void;
737
739
  }>();
738
740
  function hasReplyWaiterForTarget(from: string): boolean {
739
741
  return Array.from(replyWaiters.values()).some((waiter) => waiter.from.toLowerCase() === from.toLowerCase());
740
742
  }
741
- function waitForReply(from: string, replyTo: string, signal?: AbortSignal, onCancel?: () => void): Promise<Message> {
743
+ function waitForReply(from: string, replyTo: string, signal?: AbortSignal, onCancel?: () => void, team?: string): Promise<Message> {
742
744
  if (hasReplyWaiterForTarget(from)) {
743
745
  return Promise.reject(new Error(`Already waiting for a reply from "${from}"`));
744
746
  }
@@ -763,6 +765,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
763
765
  replyWaiters.set(replyTo, {
764
766
  from,
765
767
  replyTo,
768
+ team,
766
769
  resolve: (message) => {
767
770
  cleanup();
768
771
  resolve(message);
@@ -846,7 +849,8 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
846
849
  function currentStatus(): string {
847
850
  const activeToolName = activeTools.values().next().value;
848
851
  const busy = agentRunning || getLiveContext()?.isIdle() === false;
849
- const lifecycleStatus = activeToolName ? `tool:${activeToolName}` : busy ? "thinking" : "idle";
852
+ const settledStatus = lastRunAborted ? "cancelled" : "idle";
853
+ const lifecycleStatus = activeToolName ? `tool:${activeToolName}` : busy ? "thinking" : settledStatus;
850
854
  const queueStatus = inboundInbox?.size ? ` · inbox:${inboundInbox.size}` : "";
851
855
  const outboxStatus = client?.outboxSize ? ` · outbox:${client.outboxSize}` : "";
852
856
  return config.status ? `${lifecycleStatus}${queueStatus}${outboxStatus} · ${config.status}` : `${lifecycleStatus}${queueStatus}${outboxStatus}`;
@@ -921,6 +925,9 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
921
925
  }
922
926
  return rejectManagedJoin(classifyMembership(), isZhLocale());
923
927
  }
928
+ function taskTeamsAvailable(): boolean {
929
+ return !rejectCircleChange();
930
+ }
924
931
  async function applyCircleScope(ctx: ExtensionContext, scopeId: string, managerSessionId?: string): Promise<void> {
925
932
  if (managerSessionId) {
926
933
  process.env.AGENT_INTERCOM_MANAGER_TARGET = managerSessionId;
@@ -933,16 +940,22 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
933
940
  await switchRuntimeScope(scopeId);
934
941
  syncPresenceIdentity(currentSessionId ?? ctx.sessionManager.getSessionId());
935
942
  }
936
- async function createAndJoinNamedTeam(ctx: ExtensionContext, name: string): Promise<string> {
943
+ async function appendTaskTeam(ctx: ExtensionContext, name: string, create = false, members: string[] = [], work?: string) {
937
944
  const blocked = rejectCircleChange();
938
945
  if (blocked) throw new Error(blocked);
939
946
  const teamName = parseTeamName(name);
940
- if (!getLiveContext(ctx)) {
941
- startSessionRuntime(ctx);
947
+ if (!getLiveContext(ctx)) startSessionRuntime(ctx);
948
+ const activeClient = await ensureConnected("tool");
949
+ const memberIds: string[] = [];
950
+ for (const target of members) {
951
+ const id = await resolveSessionTarget(activeClient, target);
952
+ if (!id) throw new Error(`Team member "${target}" is not connected`);
953
+ memberIds.push(id);
942
954
  }
943
- const managerSessionId = currentSessionId ?? ctx.sessionManager.getSessionId();
944
- const team = createNamedTeam({ name: teamName, managerSessionId });
945
- await applyCircleScope(ctx, team.scopeId, team.managerSessionId);
955
+ return appendNamedTeamMembership({ name: teamName, selfId: activeClient.sessionId!, create, members: memberIds, work });
956
+ }
957
+ async function createAndJoinNamedTeam(ctx: ExtensionContext, name: string): Promise<string> {
958
+ const team = await appendTaskTeam(ctx, name, true);
946
959
  return formatCreateSuccess({ team: team.name, name: currentDisplayName(), zh: isZhLocale() });
947
960
  }
948
961
  async function listOrJoinCircle(ctx: ExtensionContext, args: string): Promise<string> {
@@ -973,10 +986,12 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
973
986
  ? (zh ? "没有这个编号的团队。" : "No team uses that number.")
974
987
  : (zh ? "无法加入该团队。" : "Could not join that team."));
975
988
  }
989
+ if (selected.kind === "named") {
990
+ await appendTaskTeam(ctx, selected.name);
991
+ return formatNamedJoinSuccess({ team: selected.name, name: currentDisplayName(), zh });
992
+ }
976
993
  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 });
994
+ return formatJoinSuccess({ workspace: selected.name, name: currentDisplayName(), zh });
980
995
  }
981
996
 
982
997
  function restoreIntercomSessionId(): void {
@@ -1021,11 +1036,13 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1021
1036
  const attachmentText = stored.message.content.attachments?.length
1022
1037
  ? formatAttachments(stored.message.content.attachments)
1023
1038
  : "";
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;
1039
+ const replyCommand = config.replyHint && stored.message.content.team
1040
+ ? `intercom_reply({ contextId: "${replyContextId(stored.from.id, stored.message.id)}", message: "..." })`
1041
+ : config.replyHint && stored.message.expectsReply
1042
+ ? batchSize === 1
1043
+ ? `intercom_reply({ message: "..." })`
1044
+ : `intercom_reply({ to: "${stored.from.id}", message: "..." })`
1045
+ : undefined;
1029
1046
  return {
1030
1047
  key: stored.key,
1031
1048
  from: stored.from,
@@ -1039,7 +1056,8 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1039
1056
  const senderDisplay = formatSessionDisplayName(entry.from);
1040
1057
  const prefix = position ? `[${position.index}/${position.total}] ` : "";
1041
1058
  const replyInstruction = entry.replyCommand ? `\n\nTo reply, use: ${entry.replyCommand}` : "";
1042
- return `**${prefix}📨 From ${senderDisplay}** (${entry.from.cwd})${replyInstruction}\n\n${entry.bodyText}`;
1059
+ const teamLabel = entry.message.content.team ? `[Team: ${sanitizeDisplayText(entry.message.content.team)}] ` : "";
1060
+ return `**${prefix}${teamLabel}📨 From ${senderDisplay}** (${entry.from.cwd})${replyInstruction}\n\n${entry.bodyText}`;
1043
1061
  }
1044
1062
  function sendIncomingBatch(entries: InboundMessageEntry[], generation = runtimeGeneration, forceTrigger = false): void {
1045
1063
  if (entries.length === 0) return;
@@ -1210,6 +1228,18 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1210
1228
  });
1211
1229
  return;
1212
1230
  }
1231
+ try {
1232
+ if (message.content.team !== undefined) {
1233
+ if (!taskTeamsAvailable()) throw new Error("Named task-team messages are unavailable in managed-team mode");
1234
+ requireNamedTeamMembers(message.content.team, receivingClient.sessionId!, from.id);
1235
+ }
1236
+ const waiter = message.replyTo ? replyWaiters.get(message.replyTo) : undefined;
1237
+ if (waiter && waiter.team !== message.content.team) throw new Error("Reply team does not match the original ask");
1238
+ } catch (error) {
1239
+ receivingClient.rejectMessage(deliveryId, getErrorMessage(error));
1240
+ pi.appendEntry("intercom_inbox_team_denied", { from: from.id, messageId: message.id, error: getErrorMessage(error), timestamp: Date.now() });
1241
+ return;
1242
+ }
1213
1243
  let enqueued;
1214
1244
  try {
1215
1245
  enqueued = inboxAtReceive.enqueue(from, message);
@@ -1566,6 +1596,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1566
1596
  runtimeInstanceId = randomUUID();
1567
1597
  lastPresenceName = buildPresenceIdentity(pi, currentSessionId).name;
1568
1598
  agentRunning = false;
1599
+ lastRunAborted = false;
1569
1600
  activeTools.clear();
1570
1601
  startNamePoll();
1571
1602
  if (authorizedRecovered.length > 0) scheduleInboundFlush(0);
@@ -1795,6 +1826,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1795
1826
  inboundLastQueuedAt = null;
1796
1827
  controlRegistrationGraceUntil = 0;
1797
1828
  agentRunning = false;
1829
+ lastRunAborted = false;
1798
1830
  activeTools.clear();
1799
1831
  if (client) {
1800
1832
  await client.disconnect(true);
@@ -1816,6 +1848,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1816
1848
  return;
1817
1849
  }
1818
1850
  agentRunning = true;
1851
+ lastRunAborted = false;
1819
1852
  activeTools.clear();
1820
1853
  syncPresenceStatus();
1821
1854
  });
@@ -1836,14 +1869,15 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1836
1869
  pi.on("agent_end", (_event, ctx) => {
1837
1870
  if (!getLiveContext(ctx)) return;
1838
1871
  replyTracker.endTurn();
1839
- agentRunning = !ctx.isIdle();
1872
+ // Only agent_settled ends the busy state; retries and continuations can still follow.
1840
1873
  activeTools.clear();
1841
1874
  syncPresenceStatus();
1842
1875
  scheduleInboundFlush(0);
1843
1876
  });
1844
- pi.on("agent_settled", (_event, ctx) => {
1877
+ pi.on("agent_settled", (event, ctx) => {
1845
1878
  if (!getLiveContext(ctx)) return;
1846
1879
  agentRunning = false;
1880
+ lastRunAborted = event.aborted === true;
1847
1881
  activeTools.clear();
1848
1882
  syncPresenceStatus();
1849
1883
  scheduleInboundFlush(0);
@@ -2226,6 +2260,8 @@ Usage:
2226
2260
  message: Type.Optional(Type.String({
2227
2261
  description: "Message to send (for 'send', 'ask', or 'reply' action)",
2228
2262
  })),
2263
+ team: Type.Optional(Type.String({ description: "Task team for sending or selecting a reply; replies cannot change the original team" })),
2264
+ contextId: Type.Optional(Type.String({ description: "Exact inbound message selector from its reply hint" })),
2229
2265
  attachments: Type.Optional(Type.Array(Type.Object({
2230
2266
  type: StringEnum(["file", "snippet", "context"] as const),
2231
2267
  name: Type.String(),
@@ -2275,7 +2311,7 @@ Usage:
2275
2311
  }
2276
2312
  }
2277
2313
 
2278
- const { action, to, message, attachments, replyTo, askId, session, which } = params;
2314
+ const { action, to, message, attachments, replyTo, askId, session, which, team, contextId } = params;
2279
2315
 
2280
2316
  switch (action) {
2281
2317
  case "list": {
@@ -2337,6 +2373,10 @@ Usage:
2337
2373
  details: { error: true },
2338
2374
  };
2339
2375
  }
2376
+ const messageTeam = taskTeamsAvailable()
2377
+ ? resolveNamedMessageTeam(connectedClient.sessionId!, sendTo, team)
2378
+ : undefined;
2379
+ if (!taskTeamsAvailable() && team !== undefined) throw new Error("Named task teams are unavailable in managed-team mode");
2340
2380
  if (!replyTo && config.confirmSend && ctx.hasUI) {
2341
2381
  const attachmentText = attachments?.length ? formatAttachments(attachments) : "";
2342
2382
  const confirmed = await ctx.ui.confirm(
@@ -2352,6 +2392,7 @@ Usage:
2352
2392
  }
2353
2393
  const result = await connectedClient.send(sendTo, {
2354
2394
  text: message,
2395
+ team: messageTeam,
2355
2396
  attachments,
2356
2397
  replyTo,
2357
2398
  });
@@ -2364,7 +2405,7 @@ Usage:
2364
2405
  }
2365
2406
  pi.appendEntry("intercom_sent", {
2366
2407
  to,
2367
- message: { text: message, attachments, replyTo },
2408
+ message: { text: message, attachments, replyTo, team: messageTeam },
2368
2409
  messageId: result.id,
2369
2410
  timestamp: Date.now(),
2370
2411
  });
@@ -2373,8 +2414,8 @@ Usage:
2373
2414
  inboundInbox?.dismissPendingAsk(replyTo, sendTo);
2374
2415
  }
2375
2416
  return {
2376
- content: [{ type: "text", text: `Message sent to ${to}` }],
2377
- details: deliveryResultDetails(result),
2417
+ content: [{ type: "text", text: `Message sent to ${to}${messageTeam ? ` [Team: ${messageTeam}]` : ""}` }],
2418
+ details: deliveryResultDetails(result, messageTeam ? { team: messageTeam } : {}),
2378
2419
  };
2379
2420
  } catch (error) {
2380
2421
  return {
@@ -2406,6 +2447,7 @@ Usage:
2406
2447
  }
2407
2448
  let replyPromise: Promise<Message> | null = null;
2408
2449
  let questionId: string | null = null;
2450
+ let messageTeam: string | undefined;
2409
2451
 
2410
2452
  try {
2411
2453
  const sendTo = await resolveAuthorizedTarget(connectedClient, to);
@@ -2427,12 +2469,17 @@ Usage:
2427
2469
  details: { error: true },
2428
2470
  };
2429
2471
  }
2472
+ messageTeam = taskTeamsAvailable()
2473
+ ? resolveNamedMessageTeam(connectedClient.sessionId!, sendTo, team)
2474
+ : undefined;
2475
+ if (!taskTeamsAvailable() && team !== undefined) throw new Error("Named task teams are unavailable in managed-team mode");
2430
2476
  questionId = randomUUID();
2431
- replyPromise = waitForReply(sendTo, questionId, _signal, () => { void connectedClient.cancelAsk(questionId!); });
2477
+ replyPromise = waitForReply(sendTo, questionId, _signal, () => { void connectedClient.cancelAsk(questionId!); }, messageTeam);
2432
2478
  replyPromise.catch(() => undefined);
2433
2479
  const sendResult = await connectedClient.send(sendTo, {
2434
2480
  messageId: questionId,
2435
2481
  text: message,
2482
+ team: messageTeam,
2436
2483
  attachments,
2437
2484
  replyTo,
2438
2485
  expectsReply: true,
@@ -2455,7 +2502,7 @@ Usage:
2455
2502
  }
2456
2503
  pi.appendEntry("intercom_sent", {
2457
2504
  to,
2458
- message: { text: message, attachments, replyTo },
2505
+ message: { text: message, attachments, replyTo, team: messageTeam },
2459
2506
  messageId: sendResult.id,
2460
2507
  timestamp: Date.now(),
2461
2508
  });
@@ -2466,17 +2513,18 @@ Usage:
2466
2513
  : "";
2467
2514
  pi.appendEntry("intercom_received", {
2468
2515
  from: to,
2469
- message: { text: replyText, attachments: replyMessage.content.attachments },
2516
+ message: { text: replyText, team: replyMessage.content.team, attachments: replyMessage.content.attachments },
2470
2517
  messageId: replyMessage.id,
2471
2518
  timestamp: replyMessage.timestamp,
2472
2519
  });
2473
2520
  return {
2474
- content: [{ type: "text", text: `**Reply from ${to}:**\n${replyText}${replyAttachments}` }],
2475
- details: {},
2521
+ content: [{ type: "text", text: `**${messageTeam ? `[Team: ${messageTeam}] ` : ""}Reply from ${to}:**\n${replyText}${replyAttachments}` }],
2522
+ details: messageTeam ? { team: messageTeam } : {},
2476
2523
  };
2477
2524
  } catch (error) {
2478
2525
  if (error instanceof AskWaitElapsedError) {
2479
- return deferredAskResult(to, error, await connectedClient.deferAsk(error.replyTo));
2526
+ const deferred = deferredAskResult(to, error, await connectedClient.deferAsk(error.replyTo));
2527
+ return { ...deferred, details: { ...deferred.details, ...(messageTeam ? { team: messageTeam } : {}) } };
2480
2528
  }
2481
2529
  if (questionId) rejectReplyWaiter(questionId, toError(error));
2482
2530
  if (replyPromise) {
@@ -2508,7 +2556,9 @@ Usage:
2508
2556
  if ("code" in requested) throw new BossTeamScopeError(requested.code, requested.error);
2509
2557
  exactReplyTarget = requested.targetId;
2510
2558
  }
2511
- const target = replyTracker.resolveReplyTarget({ to: exactReplyTarget, replyTo, askId, which });
2559
+ const target = replyTracker.resolveReplyTarget({ to: exactReplyTarget, replyTo, askId, which, team, contextId });
2560
+ const replyTeam = target.message.content.team;
2561
+ if (replyTeam !== undefined) requireNamedTeamMembers(replyTeam, connectedClient.sessionId!, target.from.id);
2512
2562
  if (bossTeamScope.present) {
2513
2563
  const resolution = resolveBossLiveTarget(bossTeamScope, target.from.id, await connectedClient.listSessions(), connectedClient.sessionId);
2514
2564
  if ("code" in resolution) throw new BossTeamScopeError(resolution.code, resolution.error);
@@ -2522,6 +2572,7 @@ Usage:
2522
2572
  const threadedReplyTo = target.message.expectsReply ? target.message.id : undefined;
2523
2573
  const result = await connectedClient.send(target.from.id, {
2524
2574
  text: message,
2575
+ team: replyTeam,
2525
2576
  ...(threadedReplyTo ? { replyTo: threadedReplyTo } : {}),
2526
2577
  });
2527
2578
  if (!result.delivered) {
@@ -2539,17 +2590,17 @@ Usage:
2539
2590
  replyTracker.markReplied(threadedReplyTo, target.from.id);
2540
2591
  inboundInbox?.dismissPendingAsk(threadedReplyTo, target.from.id);
2541
2592
  } else {
2542
- replyTracker.dismissOrdinarySender(target.from.id);
2593
+ replyTracker.dismissOrdinarySender(target.from.id, target.message.id);
2543
2594
  }
2544
2595
  pi.appendEntry("intercom_sent", {
2545
2596
  to: bossTeamScope.present ? target.from.id : target.from.name || target.from.id,
2546
- message: { text: message, ...(threadedReplyTo ? { replyTo: threadedReplyTo } : {}) },
2597
+ message: { text: message, team: replyTeam, ...(threadedReplyTo ? { replyTo: threadedReplyTo } : {}) },
2547
2598
  messageId: result.id,
2548
2599
  timestamp: Date.now(),
2549
2600
  });
2550
2601
  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 } : {}),
2602
+ content: [{ type: "text", text: `Reply sent to ${bossTeamScope.present ? target.from.id : target.from.name || target.from.id}${replyTeam ? ` [Team: ${replyTeam}]` : ""}` }],
2603
+ details: deliveryResultDetails(result, { ...(threadedReplyTo ? { replyTo: threadedReplyTo } : {}), ...(replyTeam ? { team: replyTeam } : {}) }),
2553
2604
  };
2554
2605
  } catch (error) {
2555
2606
  return {
@@ -2621,7 +2672,7 @@ Usage:
2621
2672
  : "";
2622
2673
  const sender = bossTeamScope.present ? ask.from.id : ask.from.name || ask.from.id;
2623
2674
  return {
2624
- content: [{ type: "text", text: `**Pending ask ${ask.id}**\nInbox: ${inboxSessionId}\nFrom: ${sender}\n\n${ask.message.content.text}${attachmentText}` }],
2675
+ 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
2676
  details: {
2626
2677
  inboxSessionId,
2627
2678
  ask: {
@@ -2631,6 +2682,7 @@ Usage:
2631
2682
  ...(ask.deferredAt ? { deferredAt: ask.deferredAt } : {}),
2632
2683
  preview: ask.preview,
2633
2684
  body: ask.message.content.text,
2685
+ ...(ask.message.content.team ? { team: ask.message.content.team } : {}),
2634
2686
  attachments: ask.message.content.attachments ?? [],
2635
2687
  },
2636
2688
  },
@@ -2640,7 +2692,7 @@ Usage:
2640
2692
  const lines = asks.map((ask) => {
2641
2693
  const state = ask.deferredAt ? " · async" : "";
2642
2694
  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}`;
2695
+ return `- ${ask.id} · ${sender}${ask.message.content.team ? ` · Team: ${ask.message.content.team}` : ""}${ask.selector} · ${ask.elapsedSeconds}s ago${state} · ${ask.preview}`;
2644
2696
  });
2645
2697
  return {
2646
2698
  content: [{ type: "text", text: `**Pending asks:**\n${lines.join("\n")}` }],
@@ -2652,6 +2704,7 @@ Usage:
2652
2704
  receivedAt: ask.receivedAt,
2653
2705
  ...(ask.deferredAt ? { deferredAt: ask.deferredAt } : {}),
2654
2706
  preview: ask.preview,
2707
+ ...(ask.message.content.team ? { team: ask.message.content.team } : {}),
2655
2708
  })),
2656
2709
  },
2657
2710
  };
@@ -2747,6 +2800,7 @@ Usage:
2747
2800
  promptGuidelines: ["Use intercom_send for progress updates, notifications, and other messages that do not require a conversational reply."],
2748
2801
  parameters: Type.Object({
2749
2802
  to: Type.String({ description: "Required recipient session name or stable session ID" }),
2803
+ team: Type.Optional(Type.String({ description: "Task team name. Required when both sessions share multiple teams." })),
2750
2804
  message: Type.String({ description: "Required message to send" }),
2751
2805
  attachments: attachmentParameters,
2752
2806
  }),
@@ -2763,6 +2817,7 @@ Usage:
2763
2817
  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
2818
  parameters: Type.Object({
2765
2819
  to: Type.String({ description: "Required recipient session name or stable session ID" }),
2820
+ team: Type.Optional(Type.String({ description: "Task team name. Required when both sessions share multiple teams." })),
2766
2821
  message: Type.String({ description: "Required question to ask" }),
2767
2822
  attachments: attachmentParameters,
2768
2823
  }),
@@ -2776,12 +2831,14 @@ Usage:
2776
2831
  label: "Intercom Reply",
2777
2832
  description: "Reply to an inbound intercom message or ask. Exact protocol threading is resolved internally.",
2778
2833
  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."],
2834
+ 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
2835
  parameters: Type.Object({
2781
2836
  message: Type.String({ description: "Required reply text" }),
2782
2837
  to: Type.Optional(Type.String({ description: "Optional sender/session selector when multiple senders or asks are pending; never a message or thread ID" })),
2783
2838
  which: Type.Optional(StringEnum(["oldest", "latest"] as const, { description: "Select the oldest or latest ask when the chosen sender has multiple unresolved asks" })),
2784
2839
  askId: Type.Optional(Type.String({ description: "Stable ask selector returned by intercom_pending" })),
2840
+ contextId: Type.Optional(Type.String({ description: "Exact inbound message selector from its reply hint, including ordinary messages" })),
2841
+ team: Type.Optional(Type.String({ description: "Select an original-message team; cannot override it" })),
2785
2842
  }),
2786
2843
  execute: executeSplitAction("reply"),
2787
2844
  renderCall: renderSplitCall("reply"),
@@ -2791,11 +2848,11 @@ Usage:
2791
2848
  registerIntercomTool({
2792
2849
  name: "intercom_team",
2793
2850
  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) {
2851
+ 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.",
2852
+ promptSnippet: "Find your task teams, managers, and teammates.",
2853
+ 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."],
2854
+ parameters: Type.Object({ team: Type.Optional(Type.String({ description: "Named task team to inspect; omit to show all your teams" })) }),
2855
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2799
2856
  try {
2800
2857
  const connectedClient = await ensureConnected("tool");
2801
2858
  syncPresenceIdentity(ctx.sessionManager.getSessionId());
@@ -2804,6 +2861,15 @@ Usage:
2804
2861
  if (selfError) throw new BossTeamScopeError("BOSS_TEAM_METADATA_INVALID", selfError);
2805
2862
  }
2806
2863
  const sessions = await connectedClient.listSessions();
2864
+ if (taskTeamsAvailable()) {
2865
+ const mine = sessionNamedTeams(connectedClient.sessionId!);
2866
+ const selected = params.team === undefined ? mine : mine.filter((entry) => entry.name === params.team);
2867
+ if (params.team !== undefined && selected.length === 0) throw new Error("You are not a member of that task team");
2868
+ if (selected.length > 0) {
2869
+ const teams = selected.map((entry) => namedTeamRoster(entry, connectedClient.sessionId!, sessions));
2870
+ return { content: [{ type: "text", text: teams.map(formatNamedTeamRoster).join("\n\n") }], details: { self: { id: connectedClient.sessionId }, teams } };
2871
+ }
2872
+ } else if (params.team !== undefined) throw new Error("Named task teams are unavailable in Boss mode");
2807
2873
  const team = bossTeamScope.restricted
2808
2874
  ? resolveBossIntercomTeam({ selfId: connectedClient.sessionId, sessions, scope: bossTeamScope })
2809
2875
  : await resolveIntercomTeam({ selfId: connectedClient.sessionId, sessions });
@@ -2827,26 +2893,34 @@ Usage:
2827
2893
  name: "intercom_join",
2828
2894
  executionMode: "sequential",
2829
2895
  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.",
2896
+ 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.",
2897
+ promptSnippet: "After user approval, form a task team with the named sessions they want to collaborate with.",
2832
2898
  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.",
2899
+ "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.",
2900
+ "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.",
2901
+ "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.",
2902
+ "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.",
2903
+ "Pass `team` when sending task messages, especially if peers share multiple teams. Replies inherit their original message's team automatically.",
2836
2904
  ],
2837
2905
  parameters: Type.Object({
2838
2906
  name: Type.Optional(Type.String({ description: "Team name to join. Omit to list joinable teams." })),
2839
2907
  create: Type.Optional(Type.Boolean({ description: "Create this named team and join as manager. Requires name." })),
2908
+ members: Type.Optional(Type.Array(Type.String({ description: "Connected session name or stable ID to add; manager-only, and requires name" }))),
2909
+ work: Type.Optional(Type.String({ description: "Task description when creating a team; requires create=true" })),
2840
2910
  }),
2841
2911
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2842
2912
  try {
2843
2913
  const zh = isZhLocale();
2844
- if (params.create) {
2914
+ if (params.create || params.members !== undefined || params.work !== undefined) {
2845
2915
  if (typeof params.name !== "string" || !params.name.trim()) {
2846
- throw new Error(zh ? "创建团队需要名称。" : "Creating a team requires a name.");
2916
+ throw new Error(zh ? "组队需要名称。" : "Forming a team requires a name.");
2847
2917
  }
2848
- const text = await createAndJoinNamedTeam(ctx, params.name);
2849
- return { content: [{ type: "text", text }] };
2918
+ const team = await appendTaskTeam(ctx, params.name, params.create === true, params.members ?? [], params.work);
2919
+ const roster = namedTeamRoster(team, currentSessionId!, await client!.listSessions());
2920
+ const text = params.create
2921
+ ? formatCreateSuccess({ team: team.name, name: currentDisplayName(), zh })
2922
+ : formatNamedJoinSuccess({ team: team.name, name: currentDisplayName(), zh });
2923
+ return { content: [{ type: "text", text: `${text}\n\n${formatNamedTeamRoster(roster)}` }], details: { team: roster } };
2850
2924
  }
2851
2925
  const text = await listOrJoinCircle(ctx, typeof params.name === "string" ? params.name : "");
2852
2926
  return { content: [{ type: "text", text }] };
@@ -3078,6 +3152,11 @@ Usage:
3078
3152
  const zh = isZhLocale();
3079
3153
  try {
3080
3154
  const membership = classifyMembership();
3155
+ const taskTeams = currentSessionId ? sessionNamedTeams(currentSessionId) : [];
3156
+ if (taskTeams.length > 0 && taskTeamsAvailable()) {
3157
+ notifyIfLive(ctx, `${zh ? "任务团队" : "Task teams"}: ${taskTeams.map((entry) => entry.name).join(", ")}\n${zh ? "你的显示名" : "Display name"}: ${currentDisplayName()}`, "info");
3158
+ return;
3159
+ }
3081
3160
  const namedTeam = runtimeScopeId ? findNamedTeamByScope(runtimeScopeId) : undefined;
3082
3161
  const workspace = namedTeam?.name
3083
3162
  ? 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.1",
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.1.0",
71
+ "@earendil-works/pi-coding-agent": "^1.1.0",
72
+ "@earendil-works/pi-tui": "^1.1.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)}╮`));