@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 +56 -15
- package/broker/client.ts +4 -0
- package/index.ts +132 -53
- 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
|
|
20
|
+
## Pi 1.1 compatibility and codemode
|
|
21
21
|
|
|
22
|
-
Version 0.
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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.
|
|
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.
|
|
1025
|
-
?
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", (
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
2795
|
-
promptSnippet: "Find your
|
|
2796
|
-
promptGuidelines: ["Use intercom_team
|
|
2797
|
-
parameters: Type.Object({}),
|
|
2798
|
-
async execute(_toolCallId,
|
|
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
|
|
2831
|
-
promptSnippet: "
|
|
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
|
-
"
|
|
2834
|
-
"
|
|
2835
|
-
"
|
|
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 ? "
|
|
2916
|
+
throw new Error(zh ? "组队需要名称。" : "Forming a team requires a name.");
|
|
2847
2917
|
}
|
|
2848
|
-
const
|
|
2849
|
-
|
|
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.
|
|
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
|
-
"
|
|
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.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
|
-
|
|
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)}╮`));
|