@ctliz/agent-intercom-claude 0.14.0 → 0.15.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +10 -0
- package/README.md +17 -10
- package/broker/broker.ts +3 -2
- package/broker/client.ts +5 -2
- package/claude/inbox.ts +3 -1
- package/claude/mcp-protocol.ts +31 -9
- package/claude/named-team-membership.ts +124 -0
- package/claude/named-teams.ts +11 -0
- package/claude/native-bridge.ts +13 -4
- package/claude/reply-context.ts +49 -0
- package/claude/runtime.ts +45 -31
- package/claude/server.ts +2 -1
- package/claude/team-guidance.ts +7 -0
- package/claude/worker-daemon.ts +4 -0
- package/commands/intercom-create.md +4 -2
- package/commands/intercom-join.md +1 -1
- package/dist/broker.mjs +3 -2
- package/dist/cci.mjs +23 -8
- package/dist/ccim.mjs +23 -8
- package/dist/claude-server.mjs +244 -85
- package/dist/inbox-monitor.mjs +2 -1
- package/dist/worker-daemon.mjs +11 -5
- package/package.json +1 -1
- package/skills/claude-intercom/SKILL.md +5 -5
- package/types.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.15.0 - 2026-10-02
|
|
6
|
+
|
|
7
|
+
- Add additive task teams shared with Pi, OpenCode, and Codex: one manager-approved call adds named peers without switching registration scope or losing previous memberships.
|
|
8
|
+
- Preserve task teams on sends, asks, native relays, and automatic worker replies; receiver-local ask/context selectors prevent mixed-task replies and support ordinary inbound messages.
|
|
9
|
+
- Prompt once for team approval on new delegations. Initial ungrouped contact remains possible across unrelated memberships.
|
|
10
|
+
|
|
11
|
+
## 0.14.1 - 2026-09-30
|
|
12
|
+
|
|
13
|
+
- Expose the existing MCP owner's `runtimeReady` promise to companion Grok/AGY launchers so native identity and title updates use `syncSession` without opening a competing broker connection.
|
|
14
|
+
|
|
5
15
|
## 0.14.0 - 2026-09-30
|
|
6
16
|
|
|
7
17
|
- Register ordinary MCP launches before the first prompt/tool call; packaged SessionStart/SessionEnd hooks synchronize native conversation identity without creating a competing broker connection.
|
package/README.md
CHANGED
|
@@ -185,18 +185,16 @@ With `--transport mcp`, `cci` does this automatically for each normal headless w
|
|
|
185
185
|
|
|
186
186
|
The `claude-intercom-mcp` executable is a standard stdio MCP server, but Grok Build and AGY should normally use the dedicated `@ctliz/agent-intercom-grok` and `@ctliz/agent-intercom-agy` packages. Those packages install `agent-intercom-grok-mcp` and `agent-intercom-agy-mcp`, depend on this package, and load its runtime internally. A separate global `claude-intercom-mcp` installation is not required. For a source-only checkout, configure `node` with the absolute path to `dist/claude-server.mjs`. Node 22.19+ is required.
|
|
187
187
|
|
|
188
|
-
Install the matching host package globally, then install its GitHub plugin at the matching release tag.
|
|
188
|
+
Install the matching host package globally, then install its GitHub plugin at the matching release tag. Companion launchers may await the exported `runtimeReady` promise from `dist/claude-server.mjs` and call `syncSession({ ...runtime.getIdentity(), name })` on that same runtime. This entry starts MCP stdio; do not create a second broker client to synchronize a host title.
|
|
189
189
|
|
|
190
190
|
Use `AGENT_INTERCOM_SESSION_ID` and `AGENT_INTERCOM_SESSION_NAME` for a host-neutral identity; `CLAUDE_INTERCOM_SESSION_ID` and `CLAUDE_INTERCOM_NAME` remain higher-priority compatibility aliases. Do not share a fixed session ID between concurrent host sessions.
|
|
191
191
|
|
|
192
192
|
A plain MCP host is connected only while its MCP process is alive. Incoming Intercom messages are durable and appear through `intercom_pending`, but this server cannot itself inject a new prompt into Grok or AGY. Use `intercom_send` plus `intercom_pending` for manual-polling workflows; a host-specific relay is required for wake-on-message.
|
|
193
193
|
|
|
194
|
-
For Grok Build, register the
|
|
194
|
+
For Grok Build, register the dedicated launcher without a fixed identity. It uses Grok's native `GROK_SESSION_ID` when available and synchronizes the exact session's persisted title:
|
|
195
195
|
|
|
196
196
|
```bash
|
|
197
197
|
grok mcp add agent-intercom \
|
|
198
|
-
-e AGENT_INTERCOM_SESSION_ID=grok-probe-01 \
|
|
199
|
-
-e AGENT_INTERCOM_SESSION_NAME=grok-probe \
|
|
200
198
|
-e CLAUDE_INTERCOM_MODEL=grok-build \
|
|
201
199
|
-- agent-intercom-grok-mcp
|
|
202
200
|
```
|
|
@@ -209,8 +207,6 @@ For AGY, add the equivalent entry to `~/.gemini/config/mcp_config.json`:
|
|
|
209
207
|
"agent-intercom": {
|
|
210
208
|
"command": "agent-intercom-agy-mcp",
|
|
211
209
|
"env": {
|
|
212
|
-
"AGENT_INTERCOM_SESSION_ID": "agy-probe-01",
|
|
213
|
-
"AGENT_INTERCOM_SESSION_NAME": "agy-probe",
|
|
214
210
|
"CLAUDE_INTERCOM_MODEL": "agy"
|
|
215
211
|
}
|
|
216
212
|
}
|
|
@@ -218,7 +214,7 @@ For AGY, add the equivalent entry to `~/.gemini/config/mcp_config.json`:
|
|
|
218
214
|
}
|
|
219
215
|
```
|
|
220
216
|
|
|
221
|
-
Both hosts must use the same `AGENT_INTERCOM_SCOPE_ID` as their intended peers, or leave it unset for the default local scope.
|
|
217
|
+
Both hosts must use the same `AGENT_INTERCOM_SCOPE_ID` as their intended peers, or leave it unset for the default local scope. Their persistent MCP server registers eagerly, without a first prompt or tool call. AGY's optional native metadata publisher is described in its package README. Use `intercom_whoami`, `intercom_list`, `intercom_send`, and `intercom_pending` to verify identity and delivery.
|
|
222
218
|
|
|
223
219
|
Optional identity variables can be attached at registration time:
|
|
224
220
|
|
|
@@ -266,15 +262,26 @@ tools without the skill.
|
|
|
266
262
|
## Tools
|
|
267
263
|
|
|
268
264
|
- `intercom_whoami`: show this session's intercom ID, name, cwd, and model.
|
|
269
|
-
- `intercom_team`: show
|
|
270
|
-
- `intercom_join
|
|
265
|
+
- `intercom_team({ team? })`: show all named task memberships, inspect one team, or fall back to managed-team discovery.
|
|
266
|
+
- `intercom_join({ name?, create?, members?, work? })`: add task-team membership without leaving earlier teams; managers add connected peers in one call.
|
|
271
267
|
- `intercom_status`: show connection status and pending message counts.
|
|
272
268
|
- `intercom_list`: list local Pi, Codex, and Claude sessions in your scope (protocol v4 is same-scope; cross-scope contact requires an exact full session ID).
|
|
273
269
|
- `intercom_set_summary`: publish a short discoverable status.
|
|
274
270
|
- `intercom_send`: send a non-blocking message.
|
|
275
271
|
- `intercom_ask`: send a question and wait for the target's reply.
|
|
276
272
|
- `intercom_pending`: read queued inbound messages and unresolved asks.
|
|
277
|
-
- `intercom_reply`: reply to
|
|
273
|
+
- `intercom_reply`: reply to an inbound ask or ordinary message via `askId` or `contextId` from `intercom_pending`; replies inherit the original team. Within one task, `to` plus `which: "oldest" | "latest"` also selects an ask.
|
|
274
|
+
|
|
275
|
+
### Task teams
|
|
276
|
+
|
|
277
|
+
When the user delegates to named peers, the agent asks once whether to form a team for this task and waits for approval. An explicit create/join request is already approval; inbound team messages and approved tasks do not prompt again. After discovery, one call adds everyone:
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
intercom_join({ name: "launch", create: true, members: ["front", "writer"], work: "Launch page" })
|
|
281
|
+
intercom_send({ to: "front", team: "launch", message: "Implement the header." })
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Membership is additive, with a separate manager/member role in each team. Reuse a team for the same task and append approved peers with `intercom_join({ name: "launch", members: ["reviewer"] })`; a different task may need a different team. Registration scope and earlier memberships stay unchanged. Without a shared team, omit `team` for initial ungrouped contact—even if either session belongs to unrelated teams. When several teams are shared, sends/asks require `team`; exact reply selectors retain the source team and cannot override it. AGY and Grok inherit these rules through the same MCP runtime.
|
|
278
285
|
|
|
279
286
|
Pending output never exposes protocol message IDs. Keep at most one unresolved `intercom_ask` to the same recipient; the broker rejects a second ask and recommends `intercom_send` for a non-blocking follow-up. Use `intercom_send`—not `intercom_ask`—for assignments and progress/status checkpoints.
|
|
280
287
|
|
package/broker/broker.ts
CHANGED
|
@@ -220,7 +220,7 @@ function isMessage(value: unknown): value is Message {
|
|
|
220
220
|
if (message.replyTo !== undefined || message.expectsReply !== undefined) return false;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
if (!hasExactDataKeys(message.content, ["text"], ["attachments"])) {
|
|
223
|
+
if (!hasExactDataKeys(message.content, ["text"], ["attachments", "team"])) {
|
|
224
224
|
return false;
|
|
225
225
|
}
|
|
226
226
|
|
|
@@ -229,7 +229,8 @@ function isMessage(value: unknown): value is Message {
|
|
|
229
229
|
return false;
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
if (
|
|
232
|
+
if (content.team !== undefined && (typeof content.team !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(content.team))) return false;
|
|
233
|
+
if (message.control !== undefined && (content.text !== "" || content.attachments !== undefined || content.team !== undefined)) {
|
|
233
234
|
return false;
|
|
234
235
|
}
|
|
235
236
|
|
package/broker/client.ts
CHANGED
|
@@ -43,6 +43,7 @@ export interface IntercomClientOptions {
|
|
|
43
43
|
|
|
44
44
|
export interface SendOptions {
|
|
45
45
|
text: string;
|
|
46
|
+
team?: string;
|
|
46
47
|
attachments?: Attachment[];
|
|
47
48
|
replyTo?: string;
|
|
48
49
|
expectsReply?: boolean;
|
|
@@ -122,7 +123,7 @@ function isMessage(value: unknown): value is Message {
|
|
|
122
123
|
if (message.replyTo !== undefined || message.expectsReply !== undefined) return false;
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
if (!hasExactDataKeys(message.content, ["text"], ["attachments"])) {
|
|
126
|
+
if (!hasExactDataKeys(message.content, ["text"], ["attachments", "team"])) {
|
|
126
127
|
return false;
|
|
127
128
|
}
|
|
128
129
|
|
|
@@ -131,7 +132,8 @@ function isMessage(value: unknown): value is Message {
|
|
|
131
132
|
return false;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
if (
|
|
135
|
+
if (content.team !== undefined && (typeof content.team !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(content.team))) return false;
|
|
136
|
+
if (message.control !== undefined && (content.text !== "" || content.attachments !== undefined || content.team !== undefined)) return false;
|
|
135
137
|
|
|
136
138
|
return content.attachments === undefined
|
|
137
139
|
|| isDenseArrayOf(content.attachments, isAttachment);
|
|
@@ -904,6 +906,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
904
906
|
...(options.control === undefined ? {} : { control: options.control }),
|
|
905
907
|
content: {
|
|
906
908
|
text: options.text,
|
|
909
|
+
...(options.team === undefined ? {} : { team: options.team }),
|
|
907
910
|
...(options.attachments === undefined ? {} : { attachments: options.attachments }),
|
|
908
911
|
},
|
|
909
912
|
};
|
package/claude/inbox.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface InboxEntry {
|
|
|
15
15
|
fromId: string;
|
|
16
16
|
fromName?: string;
|
|
17
17
|
messageId: string;
|
|
18
|
+
team?: string;
|
|
18
19
|
replyTo?: string;
|
|
19
20
|
expectsReply: boolean;
|
|
20
21
|
text: string;
|
|
@@ -34,6 +35,7 @@ export function toInboxEntry(from: SessionInfo, message: Message, now: number =
|
|
|
34
35
|
fromId: from.id,
|
|
35
36
|
...(from.name ? { fromName: from.name } : {}),
|
|
36
37
|
messageId: message.id,
|
|
38
|
+
...(message.content.team ? { team: message.content.team } : {}),
|
|
37
39
|
...(message.replyTo ? { replyTo: message.replyTo } : {}),
|
|
38
40
|
expectsReply: Boolean(message.expectsReply),
|
|
39
41
|
text: message.content.text,
|
|
@@ -53,7 +55,7 @@ export function formatInboxLine(entry: InboxEntry): string {
|
|
|
53
55
|
const who = entry.fromName ? `${entry.fromName} (${entry.fromId.slice(0, 8)})` : entry.fromId;
|
|
54
56
|
const kind = entry.expectsReply ? " [asking — awaiting your reply]" : "";
|
|
55
57
|
const oneLine = entry.text.replace(/\s+/g, " ").trim();
|
|
56
|
-
return `Intercom message from ${who}${kind}: ${oneLine}
|
|
58
|
+
return `Intercom message from ${who}${entry.team ? ` [Team: ${entry.team}]` : ""}${kind}: ${oneLine}\nReply with intercom_reply({ contextId: "ctx-${entry.messageId}", message: "..." }); inherit the original team.`;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/**
|
package/claude/mcp-protocol.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ClaudeIntercomRuntime, ToolResult } from "./runtime.ts";
|
|
2
2
|
import { validateAskTimeoutMs } from "../config.ts";
|
|
3
|
+
import { TASK_TEAM_GUIDANCE } from "./team-guidance.ts";
|
|
3
4
|
import type { Attachment } from "../types.ts";
|
|
4
5
|
import { hasExactDataKeys, isDenseArrayOf } from "../broker/validation.ts";
|
|
5
6
|
|
|
@@ -35,6 +36,16 @@ function asString(value: unknown, name: string): string {
|
|
|
35
36
|
return value;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function optionalString(value: unknown, name: string): string | undefined {
|
|
40
|
+
return value === undefined ? undefined : asString(value, name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function memberArray(value: unknown): string[] | undefined {
|
|
44
|
+
if (value === undefined) return undefined;
|
|
45
|
+
if (!Array.isArray(value)) throw new Error("members must be an array of session names or IDs");
|
|
46
|
+
return Array.from(value, (member) => asString(member, "member"));
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
function asBoolean(value: unknown, defaultValue: boolean): boolean {
|
|
39
50
|
return typeof value === "boolean" ? value : defaultValue;
|
|
40
51
|
}
|
|
@@ -89,26 +100,30 @@ export function buildToolDefinitions(runtime: ClaudeIntercomRuntime): ToolDefini
|
|
|
89
100
|
},
|
|
90
101
|
{
|
|
91
102
|
name: "intercom_team",
|
|
92
|
-
description: "Show your
|
|
93
|
-
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
103
|
+
description: "Show all your named task teams, or inspect one by name. Falls back to managed-team discovery.",
|
|
104
|
+
inputSchema: { type: "object", properties: { team: { type: "string" } }, additionalProperties: false },
|
|
94
105
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
95
|
-
handler: async () => runtime.team(),
|
|
106
|
+
handler: async (args) => runtime.team(optionalString(args.team, "team")),
|
|
96
107
|
},
|
|
97
108
|
{
|
|
98
109
|
name: "intercom_join",
|
|
99
|
-
description: "
|
|
110
|
+
description: "After user approval, create or join a task team. Membership is additive; members lets the manager add connected peers in one call. Omit name to list teams.",
|
|
100
111
|
inputSchema: {
|
|
101
112
|
type: "object",
|
|
102
113
|
properties: {
|
|
103
114
|
name: { type: "string", description: "Team name to join. Omit to list joinable teams." },
|
|
104
115
|
create: { type: "boolean", description: "Create this named team and join as manager. Requires name." },
|
|
116
|
+
members: { type: "array", items: { type: "string" }, description: "Connected session names or IDs to add; manager only." },
|
|
117
|
+
work: { type: "string", maxLength: 2000, description: "Task description when creating a team." },
|
|
105
118
|
},
|
|
106
119
|
additionalProperties: false,
|
|
107
120
|
},
|
|
108
121
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
109
122
|
handler: async (args) => runtime.join(
|
|
110
|
-
|
|
123
|
+
optionalString(args.name, "name"),
|
|
111
124
|
args.create === true,
|
|
125
|
+
memberArray(args.members),
|
|
126
|
+
optionalString(args.work, "work"),
|
|
112
127
|
),
|
|
113
128
|
},
|
|
114
129
|
{
|
|
@@ -156,12 +171,13 @@ export function buildToolDefinitions(runtime: ClaudeIntercomRuntime): ToolDefini
|
|
|
156
171
|
to: { type: "string" },
|
|
157
172
|
message: { type: "string" },
|
|
158
173
|
attachments: attachmentsSchema,
|
|
174
|
+
team: { type: "string", description: "Task team; required when multiple teams are shared. Omit for ungrouped contact." },
|
|
159
175
|
},
|
|
160
176
|
required: ["to", "message"],
|
|
161
177
|
additionalProperties: false,
|
|
162
178
|
},
|
|
163
179
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
164
|
-
handler: async (args) => runtime.send(asString(args.to, "to"), asString(args.message, "message"), asAttachmentArray(args.attachments)),
|
|
180
|
+
handler: async (args) => runtime.send(asString(args.to, "to"), asString(args.message, "message"), asAttachmentArray(args.attachments), undefined, optionalString(args.team, "team")),
|
|
165
181
|
},
|
|
166
182
|
{
|
|
167
183
|
name: "intercom_ask",
|
|
@@ -172,6 +188,7 @@ export function buildToolDefinitions(runtime: ClaudeIntercomRuntime): ToolDefini
|
|
|
172
188
|
to: { type: "string" },
|
|
173
189
|
message: { type: "string" },
|
|
174
190
|
attachments: attachmentsSchema,
|
|
191
|
+
team: { type: "string", description: "Task team; required when multiple teams are shared." },
|
|
175
192
|
timeout_ms: { type: "integer", minimum: 1, maximum: 120000, description: "Maximum time to wait for a reply before returning an error. Use intercom_send plus intercom_pending for longer work." },
|
|
176
193
|
},
|
|
177
194
|
required: ["to", "message"],
|
|
@@ -184,6 +201,7 @@ export function buildToolDefinitions(runtime: ClaudeIntercomRuntime): ToolDefini
|
|
|
184
201
|
asAttachmentArray(args.attachments),
|
|
185
202
|
asOptionalPositiveInteger(args.timeout_ms, "timeout_ms"),
|
|
186
203
|
signal,
|
|
204
|
+
optionalString(args.team, "team"),
|
|
187
205
|
),
|
|
188
206
|
},
|
|
189
207
|
{
|
|
@@ -199,19 +217,22 @@ export function buildToolDefinitions(runtime: ClaudeIntercomRuntime): ToolDefini
|
|
|
199
217
|
},
|
|
200
218
|
{
|
|
201
219
|
name: "intercom_reply",
|
|
202
|
-
description: "Reply to
|
|
220
|
+
description: "Reply to an inbound ask or ordinary message using askId or contextId from intercom_pending. Inherits the original team; team cannot override it.",
|
|
203
221
|
inputSchema: {
|
|
204
222
|
type: "object",
|
|
205
223
|
properties: {
|
|
206
224
|
message: { type: "string" },
|
|
207
225
|
to: { type: "string", description: "Optional sender/session selector; never a message or thread ID." },
|
|
226
|
+
askId: { type: "string", description: "Stable receiver-local pending ask selector." },
|
|
227
|
+
contextId: { type: "string", description: "Exact inbound context selector, including ordinary messages." },
|
|
228
|
+
team: { type: "string", description: "Must match the original message; omit to inherit." },
|
|
208
229
|
which: { type: "string", enum: ["oldest", "latest"], description: "Select the oldest or latest ask from the chosen sender." },
|
|
209
230
|
},
|
|
210
231
|
required: ["message"],
|
|
211
232
|
additionalProperties: false,
|
|
212
233
|
},
|
|
213
234
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
214
|
-
handler: async (args) => runtime.reply(asString(args.message, "message"), typeof args.to === "string" ? args.to : undefined, args.which === "oldest" || args.which === "latest" ? args.which : undefined),
|
|
235
|
+
handler: async (args) => runtime.reply(asString(args.message, "message"), typeof args.to === "string" ? args.to : undefined, args.which === "oldest" || args.which === "latest" ? args.which : undefined, optionalString(args.askId, "askId"), optionalString(args.contextId, "contextId"), optionalString(args.team, "team")),
|
|
215
236
|
},
|
|
216
237
|
];
|
|
217
238
|
return tools;
|
|
@@ -248,7 +269,8 @@ export async function handleMcpRequest(request: JsonRpcRequest, runtime: ClaudeI
|
|
|
248
269
|
return ok(request.id, {
|
|
249
270
|
protocolVersion: "2025-06-18",
|
|
250
271
|
capabilities: { tools: {} },
|
|
251
|
-
|
|
272
|
+
instructions: TASK_TEAM_GUIDANCE,
|
|
273
|
+
serverInfo: { name: "claude-intercom", version: "0.15.0" },
|
|
252
274
|
});
|
|
253
275
|
case "ping":
|
|
254
276
|
return ok(request.id, {});
|
|
@@ -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 agent 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/claude/named-teams.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface NamedTeam {
|
|
|
13
13
|
scopeId: string;
|
|
14
14
|
managerSessionId: string;
|
|
15
15
|
createdAt: number;
|
|
16
|
+
memberSessionIds?: string[];
|
|
17
|
+
work?: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
interface NamedTeamsFile {
|
|
@@ -55,11 +57,20 @@ function parseStoredTeam(value: unknown): NamedTeam | undefined {
|
|
|
55
57
|
if (typeof value.createdAt !== "number" || !Number.isSafeInteger(value.createdAt) || value.createdAt <= 0) {
|
|
56
58
|
return undefined;
|
|
57
59
|
}
|
|
60
|
+
if (value.memberSessionIds !== undefined && (
|
|
61
|
+
!Array.isArray(value.memberSessionIds)
|
|
62
|
+
|| !value.memberSessionIds.every((id) => typeof id === "string" && id.trim() === id && id.length > 0 && !/[\u0000-\u001f\u007f]/.test(id))
|
|
63
|
+
|| new Set(value.memberSessionIds).size !== value.memberSessionIds.length
|
|
64
|
+
|| !value.memberSessionIds.includes(value.managerSessionId)
|
|
65
|
+
)) return undefined;
|
|
66
|
+
if (value.work !== undefined && (typeof value.work !== "string" || !value.work.trim() || value.work.length > 2000)) return undefined;
|
|
58
67
|
return {
|
|
59
68
|
name: value.name,
|
|
60
69
|
scopeId: value.scopeId,
|
|
61
70
|
managerSessionId: value.managerSessionId,
|
|
62
71
|
createdAt: value.createdAt,
|
|
72
|
+
...(value.memberSessionIds === undefined ? {} : { memberSessionIds: value.memberSessionIds as string[] }),
|
|
73
|
+
...(value.work === undefined ? {} : { work: value.work as string }),
|
|
63
74
|
};
|
|
64
75
|
}
|
|
65
76
|
|
package/claude/native-bridge.ts
CHANGED
|
@@ -35,7 +35,7 @@ interface NativeBridgeClient {
|
|
|
35
35
|
}, sessionId?: string): Promise<void>;
|
|
36
36
|
disconnect(): Promise<void>;
|
|
37
37
|
acknowledgeMessage(deliveryId: string): void;
|
|
38
|
-
send(to: string, message: { text: string; replyTo?: string }): Promise<{ delivered: boolean; reason?: string }>;
|
|
38
|
+
send(to: string, message: { text: string; replyTo?: string; team?: string }): Promise<{ delivered: boolean; reason?: string }>;
|
|
39
39
|
updatePresence(update: { name?: string; status?: string }): void;
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -72,6 +72,8 @@ function nativePrompt(from: SessionInfo, message: Message): string {
|
|
|
72
72
|
: "\n\nAct if needed. To acknowledge or report a result, use Claude's built-in SendMessage tool to reply to this same native peer; a normal assistant response stays local.";
|
|
73
73
|
return [
|
|
74
74
|
`[Intercom message from ${formatSessionDisplay(from)} (${from.id})]`,
|
|
75
|
+
...(message.content.team ? [`[Team: ${message.content.team}]`] : []),
|
|
76
|
+
`When replying via SendMessage, begin the body with [intercom-context:ctx-${message.id}] so this exact task is retained.`,
|
|
75
77
|
message.content.text,
|
|
76
78
|
formatAttachments(message.content.attachments),
|
|
77
79
|
reply,
|
|
@@ -175,7 +177,7 @@ export class NativeClaudeBrokerBridge {
|
|
|
175
177
|
const reason = error instanceof Error ? error.message : String(error);
|
|
176
178
|
this.client.updatePresence({ status: `native delivery error: ${reason}` });
|
|
177
179
|
if (message.expectsReply) {
|
|
178
|
-
await this.client.send(from.id, { text: `Native Claude delivery failed: ${reason}`, replyTo: message.id }).catch(() => undefined);
|
|
180
|
+
await this.client.send(from.id, { text: `Native Claude delivery failed: ${reason}`, replyTo: message.id, ...(message.content.team === undefined ? {} : { team: message.content.team }) }).catch(() => undefined);
|
|
179
181
|
}
|
|
180
182
|
}
|
|
181
183
|
}
|
|
@@ -186,7 +188,13 @@ export class NativeClaudeBrokerBridge {
|
|
|
186
188
|
if (typeof message !== "object" || message === null || !("content" in message)) return;
|
|
187
189
|
const envelope = stripNativeEnvelope((message as { content?: unknown }).content);
|
|
188
190
|
if (!envelope.body) return;
|
|
189
|
-
const
|
|
191
|
+
const selector = /^\[intercom-context:ctx-([^\]\r\n]+)\]\s*/.exec(envelope.body);
|
|
192
|
+
const index = selector ? this.pending.findIndex((entry) => entry.message.id === selector[1]) : (this.pending.length === 1 ? 0 : -1);
|
|
193
|
+
if (index < 0) {
|
|
194
|
+
this.client.updatePresence({ status: "Reply requires the original intercom-context selector" });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const relay = this.pending.splice(index, 1)[0];
|
|
190
198
|
const fromAddress = typeof frame.from === "string" ? frame.from : envelope.from;
|
|
191
199
|
if (fromAddress?.startsWith("uds:")) {
|
|
192
200
|
void this.sendFrame(fromAddress.slice(4), buildNativeReceipt({
|
|
@@ -198,7 +206,8 @@ export class NativeClaudeBrokerBridge {
|
|
|
198
206
|
}
|
|
199
207
|
if (!relay) return;
|
|
200
208
|
void this.client.send(relay.from.id, {
|
|
201
|
-
text: envelope.body,
|
|
209
|
+
text: selector ? envelope.body.slice(selector[0].length) : envelope.body,
|
|
210
|
+
...(relay.message.content.team === undefined ? {} : { team: relay.message.content.team }),
|
|
202
211
|
...(relay.message.expectsReply ? { replyTo: relay.message.id } : {}),
|
|
203
212
|
}).then(() => {
|
|
204
213
|
this.client.updatePresence({ status: "idle" });
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { PendingInboundMessage, ReplyWhich } from "./runtime.ts";
|
|
2
|
+
import { selectPendingAsk } from "./runtime.ts";
|
|
3
|
+
|
|
4
|
+
export function contextId(entry: PendingInboundMessage): string {
|
|
5
|
+
return `ctx-${entry.message.id}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function askId(entry: PendingInboundMessage): string {
|
|
9
|
+
return `ask-${entry.message.id}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function replyHint(entry: PendingInboundMessage): string {
|
|
13
|
+
const team = entry.message.content.team;
|
|
14
|
+
return `${team ? ` [Team: ${team}]` : ""} [contextId: ${contextId(entry)}]${entry.message.expectsReply ? ` [askId: ${askId(entry)}]` : ""}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Select from the receiver's own retained inbox, never from a mutable current team. */
|
|
18
|
+
export function selectReplyContext(entries: PendingInboundMessage[], asks: PendingInboundMessage[], input: {
|
|
19
|
+
to?: string;
|
|
20
|
+
which?: ReplyWhich;
|
|
21
|
+
askId?: string;
|
|
22
|
+
contextId?: string;
|
|
23
|
+
team?: string;
|
|
24
|
+
}): PendingInboundMessage {
|
|
25
|
+
if (input.askId && input.contextId) throw new Error("Specify askId or contextId, not both");
|
|
26
|
+
let target: PendingInboundMessage;
|
|
27
|
+
if (input.askId || input.contextId) {
|
|
28
|
+
const selected = input.askId
|
|
29
|
+
? asks.find((entry) => askId(entry) === input.askId)
|
|
30
|
+
: entries.find((entry) => contextId(entry) === input.contextId);
|
|
31
|
+
if (!selected || (selected.message.expectsReply && !asks.includes(selected))) throw new Error("Unknown or resolved reply selector; call intercom_pending");
|
|
32
|
+
if (input.to && selected.from.id !== input.to && selected.from.name?.toLowerCase() !== input.to.toLowerCase()) {
|
|
33
|
+
throw new Error("Reply sender does not match the selected context");
|
|
34
|
+
}
|
|
35
|
+
if (input.which) throw new Error("Do not combine an exact reply selector with which");
|
|
36
|
+
target = selected;
|
|
37
|
+
} else {
|
|
38
|
+
const to = input.to;
|
|
39
|
+
const candidates = to ? asks.filter((entry) => entry.from.id === to || entry.from.name?.toLowerCase() === to.toLowerCase() || entry.from.id.startsWith(to)) : asks;
|
|
40
|
+
if (!input.which && new Set(candidates.map((entry) => entry.message.content.team)).size > 1) {
|
|
41
|
+
throw new Error("Multiple team contexts; specify askId or contextId");
|
|
42
|
+
}
|
|
43
|
+
target = selectPendingAsk(asks, input.to, input.which);
|
|
44
|
+
}
|
|
45
|
+
if (input.team !== undefined && input.team !== target.message.content.team) {
|
|
46
|
+
throw new Error("Reply team must match the original message; omit team to inherit it");
|
|
47
|
+
}
|
|
48
|
+
return target;
|
|
49
|
+
}
|