@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 +55 -14
- package/broker/client.ts +4 -0
- package/index.ts +123 -50
- package/named-team-membership.ts +124 -0
- package/named-teams.ts +12 -0
- package/package.json +11 -5
- package/reply-tracker.ts +36 -13
- package/skills/pi-intercom/SKILL.md +60 -7
- package/types.ts +2 -0
- package/ui/inline-message.ts +2 -1
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
|
|
20
|
+
## Pi 1.0 compatibility and codemode
|
|
21
21
|
|
|
22
|
-
Version 0.13.0
|
|
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
|
|
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.
|
|
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
|
|
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({})`
|
|
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
|
|
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`,
|
|
634
|
-
| `intercom_ask` | required `to`,
|
|
635
|
-
| `intercom_reply` | required `message
|
|
636
|
-
| `intercom_team` |
|
|
637
|
-
| `intercom_join` | optional `name`,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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.
|
|
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.
|
|
1025
|
-
?
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
2795
|
-
promptSnippet: "Find your
|
|
2796
|
-
promptGuidelines: ["Use intercom_team
|
|
2797
|
-
parameters: Type.Object({}),
|
|
2798
|
-
async execute(_toolCallId,
|
|
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
|
|
2831
|
-
promptSnippet: "
|
|
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
|
-
"
|
|
2834
|
-
"
|
|
2835
|
-
"
|
|
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 ? "
|
|
2910
|
+
throw new Error(zh ? "组队需要名称。" : "Forming a team requires a name.");
|
|
2847
2911
|
}
|
|
2848
|
-
const
|
|
2849
|
-
|
|
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.
|
|
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
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"
|
|
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
|
-
|
|
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
|
-
?
|
|
128
|
-
:
|
|
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
|
|
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
|
-
|
|
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 })
|
|
98
|
-
|
|
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
|
};
|
package/ui/inline-message.ts
CHANGED
|
@@ -47,7 +47,8 @@ export class InlineMessageComponent implements Component {
|
|
|
47
47
|
}
|
|
48
48
|
const bodyWidth = Math.max(1, width - 2);
|
|
49
49
|
|
|
50
|
-
const
|
|
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)}╮`));
|