@ctliz/agent-intercom-claude 0.14.1 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-intercom",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Claude Code MCP plugin and wakeable worker for local Pi/Codex/Claude intercom messaging.",
5
5
  "author": {
6
6
  "name": "ctliz",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
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
+
5
11
  ## 0.14.1 - 2026-09-30
6
12
 
7
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.
package/README.md CHANGED
@@ -262,15 +262,26 @@ tools without the skill.
262
262
  ## Tools
263
263
 
264
264
  - `intercom_whoami`: show this session's intercom ID, name, cwd, and model.
265
- - `intercom_team`: show the current manager and live coworkers owned by that manager.
266
- - `intercom_join`: list, join, or create a named team without tmux (`create: true` joins as manager).
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.
267
267
  - `intercom_status`: show connection status and pending message counts.
268
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).
269
269
  - `intercom_set_summary`: publish a short discoverable status.
270
270
  - `intercom_send`: send a non-blocking message.
271
271
  - `intercom_ask`: send a question and wait for the target's reply.
272
272
  - `intercom_pending`: read queued inbound messages and unresolved asks.
273
- - `intercom_reply`: reply to a pending inbound ask; use `to` plus `which: "oldest" | "latest"` if one sender has multiple unresolved asks.
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.
274
285
 
275
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.
276
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 (message.control !== undefined && (content.text !== "" || content.attachments !== undefined)) {
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 (message.control !== undefined && (content.text !== "" || content.attachments !== undefined)) return false;
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
  /**
@@ -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 current manager and the live coworkers owned by that manager. No arguments are required.",
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: "List, join, or create a named intercom team without tmux. Omit name to list joinable teams. Set create=true to create a team and join as manager.",
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
- typeof args.name === "string" ? args.name : undefined,
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 a pending inbound ask. Use to plus which=oldest/latest when one sender has multiple unresolved asks.",
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
- serverInfo: { name: "claude-intercom", version: "0.14.0" },
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
+ }
@@ -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
 
@@ -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 relay = this.pending.shift();
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
+ }