@ctliz/agent-intercom-pi 0.12.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -10
- package/bin/intercom-send.mjs +4 -0
- package/broker/client.ts +7 -1
- package/broker/framing.ts +4 -1
- package/cli-send.ts +39 -0
- package/index.ts +180 -72
- package/named-team-membership.ts +124 -0
- package/named-teams.ts +12 -0
- package/package.json +12 -2
- package/reply-tracker.ts +36 -13
- package/skills/pi-intercom/SKILL.md +74 -8
- package/tool-result.ts +26 -0
- package/types.ts +2 -0
- package/ui/inline-message.ts +2 -1
|
@@ -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,15 +1,19 @@
|
|
|
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",
|
|
7
7
|
"main": "index.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"intercom-send": "./bin/intercom-send.mjs"
|
|
10
|
+
},
|
|
8
11
|
"exports": {
|
|
9
12
|
".": "./index.ts"
|
|
10
13
|
},
|
|
11
14
|
"files": [
|
|
12
15
|
"banner.png",
|
|
16
|
+
"bin/*.mjs",
|
|
13
17
|
"*.ts",
|
|
14
18
|
"!*.test.ts",
|
|
15
19
|
"broker/**/*.ts",
|
|
@@ -54,14 +58,20 @@
|
|
|
54
58
|
},
|
|
55
59
|
"dependencies": {
|
|
56
60
|
"@ctliz/agent-intercom-core": "0.2.0",
|
|
61
|
+
"tsx": "^4.20.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
57
64
|
"@earendil-works/pi-ai": "*",
|
|
58
65
|
"@earendil-works/pi-coding-agent": "*",
|
|
59
66
|
"@earendil-works/pi-tui": "*",
|
|
60
|
-
"tsx": "^4.20.0",
|
|
61
67
|
"typebox": "*"
|
|
62
68
|
},
|
|
63
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",
|
|
64
73
|
"@types/node": "^22.15.0",
|
|
74
|
+
"typebox": "*",
|
|
65
75
|
"typescript": "^5.9.3"
|
|
66
76
|
},
|
|
67
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,73 @@ 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
|
+
|
|
81
|
+
## Codemode (Pi 0.99.1+)
|
|
82
|
+
|
|
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.
|
|
84
|
+
|
|
85
|
+
```javascript
|
|
86
|
+
const result = await tools.intercom_send({ to: "worker", message: "Tests passed." });
|
|
87
|
+
return { ok: result.ok, delivered: result.data.delivered };
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A deferred ask has `ok: true` and `data.pending: true`; it is not a failure. Do not proceed with dependent work until the actual answer arrives. Different recipients may be asked concurrently; never create a second unresolved ask to the same recipient. Delivery means durable queue acknowledgement, not task completion.
|
|
91
|
+
|
|
92
|
+
For shell notifications, use `intercom-send <session-name-or-id> <message>`. It registers an independent send-only identity and prints JSON; it does not take over the current Pi session or track replies.
|
|
93
|
+
|
|
27
94
|
## Core Patterns
|
|
28
95
|
|
|
29
96
|
### Pattern 1: Planner-Worker Delegation
|
|
@@ -78,12 +145,11 @@ Do not add artificial sleeps to form a batch. Each original message keeps its se
|
|
|
78
145
|
|
|
79
146
|
### Pattern 3: Find Your Manager or Team
|
|
80
147
|
|
|
81
|
-
|
|
148
|
+
For independent Pi sessions, ask for approval first, then create a task team and add the connected peer:
|
|
82
149
|
|
|
83
150
|
```typescript
|
|
84
|
-
intercom_join({ name: "billing", create: true })
|
|
85
|
-
|
|
86
|
-
intercom_team({})
|
|
151
|
+
intercom_join({ name: "billing", create: true, members: ["worker"], work: "Implement billing" })
|
|
152
|
+
intercom_team({ team: "billing" })
|
|
87
153
|
```
|
|
88
154
|
|
|
89
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.
|
|
@@ -122,7 +188,7 @@ intercom_pending({ askId: "ask-..." }) // Retrieve the full untruncated body whe
|
|
|
122
188
|
intercom_reply({ askId: "ask-...", message: "Use exponential backoff starting at 100ms." })
|
|
123
189
|
```
|
|
124
190
|
|
|
125
|
-
`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.
|
|
126
192
|
|
|
127
193
|
### Pattern 5: Broadcast to Multiple Workers
|
|
128
194
|
|
|
@@ -467,7 +533,7 @@ if (result.details?.pending) {
|
|
|
467
533
|
|
|
468
534
|
### Session name flips or registration reports `SESSION_ID_IN_USE`
|
|
469
535
|
|
|
470
|
-
The same Pi session is open in more than one live runtime, such as a desktop terminal and a mobile/RPC host. Intercom keeps the first runtime authoritative instead of allowing the two clients to evict each other.
|
|
536
|
+
The same Pi session is open in more than one live runtime, such as a desktop terminal and a mobile/RPC host. Intercom keeps the first runtime authoritative instead of allowing the two clients to evict each other. The conflicting runtime pauses automatic reconnect; `intercom_status` returns `ok: false` with `data.code: "SESSION_ID_IN_USE"`. Switch to a different session, or release the duplicate owner and `/reload`. One transcript/session ID must have only one live owner; do not auto-generate a replacement identity for a running session.
|
|
471
537
|
|
|
472
538
|
### Message not delivered
|
|
473
539
|
|
package/tool-result.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
export const intercomOutputSchema = Type.Object({
|
|
5
|
+
ok: Type.Boolean(),
|
|
6
|
+
text: Type.String(),
|
|
7
|
+
data: Type.Record(Type.String(), Type.Unknown()),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
type ToolResult = Awaited<ReturnType<ToolDefinition<any, any>["execute"]>>;
|
|
11
|
+
|
|
12
|
+
/** Keep model/TUI content unchanged while giving codemode callers JSON data. */
|
|
13
|
+
export function structuredIntercomResult(result: ToolResult): ToolResult {
|
|
14
|
+
const details = result.details ?? {};
|
|
15
|
+
const isError = result.isError === true || details.error === true || details.delivered === false;
|
|
16
|
+
return {
|
|
17
|
+
...result,
|
|
18
|
+
details,
|
|
19
|
+
isError,
|
|
20
|
+
structuredContent: {
|
|
21
|
+
ok: !isError,
|
|
22
|
+
text: result.content.filter((item) => item.type === "text").map((item) => item.text).join("\n"),
|
|
23
|
+
data: JSON.parse(JSON.stringify(details)),
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
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)}╮`));
|