@ctliz/agent-intercom-codex 0.12.2 → 0.13.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": "codex-intercom",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
4
4
  "description": "Codex MCP plugin and bridge daemon for local Pi/Codex 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.13.0 - 2026-10-02
6
+
7
+ - Add additive task teams shared with Pi, Claude, and OpenCode; managers can add connected peers and task descriptions in one call without changing registration scopes.
8
+ - Expose exact pending ask/context selectors; ordinary-message replies and blocking-ask replies inherit the original task team, never a mutable current team.
9
+ - Preserve teams in app-server worker replies and tools; add one-time approval guidance and allow initial contact across unrelated memberships.
10
+
5
11
  ## 0.12.2 - 2026-09-30
6
12
 
7
13
  - Synchronize native `thread/name/updated` notifications into Intercom presence without changing stable session IDs; retain the updated name on broker reconnect.
package/README.md CHANGED
@@ -186,15 +186,26 @@ intercom tools.
186
186
  ## Tools
187
187
 
188
188
  - `intercom_whoami`: show this session's intercom ID, name, cwd, and model.
189
- - `intercom_team`: show the current manager and live coworkers owned by that manager.
190
- - `intercom_join`: list, join, or create a named team without tmux (`create: true` joins as manager).
189
+ - `intercom_team({ team? })`: show all named task memberships, inspect one team, or fall back to managed-team discovery.
190
+ - `intercom_join({ name?, create?, members?, work? })`: add task-team membership without leaving earlier teams; managers add connected peers in one call.
191
191
  - `intercom_status`: show connection status and pending message counts.
192
192
  - `intercom_list`: list local Pi, Codex, Claude Code, and OpenCode sessions in your scope (protocol v4 is same-scope; cross-scope contact requires an exact full session ID).
193
193
  - `intercom_set_summary`: publish a short discoverable status.
194
194
  - `intercom_send`: send a non-blocking message.
195
195
  - `intercom_ask`: send a question and wait for the target's reply.
196
196
  - `intercom_pending`: read queued inbound messages and unresolved asks.
197
- - `intercom_reply`: reply to a pending inbound ask; use `to` plus `which: "oldest" | "latest"` if one sender has multiple unresolved asks.
197
+ - `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.
198
+
199
+ ### Task teams
200
+
201
+ When the user delegates to named peers, the agent asks once whether to form a team for this task and waits for approval. Explicit create/join requests are already approval; approved tasks and inbound team messages do not prompt again. Discover the peers, then add everyone in one call:
202
+
203
+ ```typescript
204
+ intercom_join({ name: "launch", create: true, members: ["front", "writer"], work: "Launch page" })
205
+ intercom_send({ to: "front", team: "launch", message: "Implement the header." })
206
+ ```
207
+
208
+ Membership and roles are per task and additive; joining preserves earlier teams and registration scope. Reuse an approved team for the same task and append approved peers as manager. For initial contact without a shared team, omit `team`; unrelated memberships do not block messaging or silently create teams. Multiple shared teams require `team` on sends/asks. Replies retain the original message's team via exact selectors, not a mutable current team. App-server workers also retain the team on automatic replies.
198
209
 
199
210
  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.
200
211
 
package/broker/broker.ts CHANGED
@@ -240,6 +240,7 @@ function isMessage(value: unknown): value is Message {
240
240
  return false;
241
241
  }
242
242
 
243
+ if (content.team !== undefined && (typeof content.team !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(content.team))) return false;
243
244
  return content.attachments === undefined
244
245
  || (
245
246
  Array.isArray(content.attachments)
package/broker/client.ts CHANGED
@@ -41,6 +41,7 @@ import type {
41
41
 
42
42
  export interface SendOptions {
43
43
  text: string;
44
+ team?: string;
44
45
  attachments?: Attachment[];
45
46
  replyTo?: string;
46
47
  expectsReply?: boolean;
@@ -134,6 +135,7 @@ function isMessage(value: unknown): value is Message {
134
135
  return false;
135
136
  }
136
137
 
138
+ if (content.team !== undefined && (typeof content.team !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(content.team))) return false;
137
139
  return content.attachments === undefined
138
140
  || (Array.isArray(content.attachments) && content.attachments.every(isAttachment));
139
141
  }
@@ -905,6 +907,7 @@ export class IntercomClient extends EventEmitter {
905
907
  expectsReply: options.expectsReply,
906
908
  content: {
907
909
  text: options.text,
910
+ ...(options.team === undefined ? {} : { team: options.team }),
908
911
  attachments: options.attachments,
909
912
  },
910
913
  };
@@ -23,6 +23,9 @@ import type { Message, SessionInfo } from "../types.ts";
23
23
  import { resolveContactTarget, type IntercomContact } from "./contact.ts";
24
24
  import { formatAttachments, formatSessionDisplay, formatSessionList, resolveSessionTarget, type ToolResult } from "./runtime.ts";
25
25
  import { formatIntercomTeam, resolveIntercomTeam } from "./team.ts";
26
+ import { appendNamedTeamMembership, sessionNamedTeams, namedTeamRoster, formatNamedTeamRoster, resolveNamedMessageTeam } from "./named-team-membership.ts";
27
+ import { formatJoinableNamedTeamList, listNamedTeams } from "./named-teams.ts";
28
+ import { TASK_TEAM_GUIDANCE } from "./team-guidance.ts";
26
29
 
27
30
  interface TurnWaiter {
28
31
  from: SessionInfo;
@@ -31,6 +34,7 @@ interface TurnWaiter {
31
34
 
32
35
  interface ToolReplyWaiter {
33
36
  from: string;
37
+ team?: string;
34
38
  resolve: (message: Message) => void;
35
39
  reject: (error: Error) => void;
36
40
  timeout: NodeJS.Timeout;
@@ -58,6 +62,7 @@ export interface VirtualCodexAgentOptions {
58
62
  const APPROVED_INTERCOM_TOOLS = new Set([
59
63
  "intercom_whoami",
60
64
  "intercom_team",
65
+ "intercom_join",
61
66
  "intercom_status",
62
67
  "intercom_list",
63
68
  "intercom_set_summary",
@@ -88,6 +93,7 @@ function formatMessage(from: SessionInfo, message: Message, agent: BridgeAgentCo
88
93
  return [
89
94
  `Intercom message for ${agent.name}.`,
90
95
  `From: ${formatSessionDisplay(from)} (${from.id})`,
96
+ ...(message.content.team ? [`[Team: ${message.content.team}]`] : []),
91
97
  `Message id: ${message.id}`,
92
98
  "",
93
99
  message.content.text,
@@ -191,7 +197,7 @@ function getCompletedAgentText(params: unknown): string | null {
191
197
  return raw.type === "agentMessage" && typeof raw.text === "string" ? raw.text : null;
192
198
  }
193
199
 
194
- function intercomSendFromArgs(rawArgs: unknown): { to: string; message: string } | null {
200
+ function intercomSendFromArgs(rawArgs: unknown): { to: string; message: string; team?: string } | null {
195
201
  let args: Record<string, unknown>;
196
202
  try {
197
203
  args = parseToolArguments(rawArgs);
@@ -199,11 +205,11 @@ function intercomSendFromArgs(rawArgs: unknown): { to: string; message: string }
199
205
  return null;
200
206
  }
201
207
  return typeof args.to === "string" && typeof args.message === "string"
202
- ? { to: args.to, message: args.message }
208
+ ? { to: args.to, message: args.message, ...(typeof args.team === "string" ? { team: args.team } : {}) }
203
209
  : null;
204
210
  }
205
211
 
206
- export function getCompletedIntercomSend(params: unknown): { to: string; message: string } | null {
212
+ export function getCompletedIntercomSend(params: unknown): { to: string; message: string; team?: string } | null {
207
213
  if (!params || typeof params !== "object") return null;
208
214
  const item = (params as Record<string, unknown>).item;
209
215
  if (!isRecord(item)) return null;
@@ -212,7 +218,7 @@ export function getCompletedIntercomSend(params: unknown): { to: string; message
212
218
  return intercomSendFromArgs(item.arguments ?? item.args ?? item.input);
213
219
  }
214
220
 
215
- export function getApprovedIntercomSend(params: unknown): { to: string; message: string } | null {
221
+ export function getApprovedIntercomSend(params: unknown): { to: string; message: string; team?: string } | null {
216
222
  if (getApprovedIntercomToolFromApproval(params) !== "intercom_send") return null;
217
223
  if (!isRecord(params)) return null;
218
224
  const meta = isRecord(params._meta) ? params._meta : {};
@@ -234,7 +240,7 @@ function asOptionalPositiveInteger(value: unknown, name: string): number | undef
234
240
  }
235
241
 
236
242
  function normalizeToolName(name: string): string {
237
- const mcpMatch = name.match(/(?:^|__|\.)intercom_(whoami|status|list|set_summary|send|ask|pending|reply)$/);
243
+ const mcpMatch = name.match(/(?:^|__|\.)intercom_(whoami|team|join|status|list|set_summary|send|ask|pending|reply)$/);
238
244
  if (mcpMatch) return `intercom_${mcpMatch[1]}`;
239
245
  return name;
240
246
  }
@@ -511,7 +517,7 @@ export class VirtualCodexAgent {
511
517
  approvalPolicy: bridgeAgentApprovalPolicy(this.agent),
512
518
  sandbox,
513
519
  serviceName: "codex-intercom",
514
- developerInstructions: this.agent.instructions ?? null,
520
+ developerInstructions: [this.agent.instructions, TASK_TEAM_GUIDANCE].filter(Boolean).join("\n\n"),
515
521
  threadSource: "cli",
516
522
  });
517
523
  this.threadId = getThreadId(result);
@@ -525,7 +531,7 @@ export class VirtualCodexAgent {
525
531
  private routeMessage(from: SessionInfo, message: Message): Promise<void> {
526
532
  const toolWaiter = this.toolReplyWaiters.get(message.replyTo ?? "");
527
533
  if (toolWaiter) {
528
- if (from.id === toolWaiter.from) {
534
+ if (from.id === toolWaiter.from && message.content.team === toolWaiter.team) {
529
535
  this.toolReplyWaiters.delete(message.replyTo ?? "");
530
536
  clearTimeout(toolWaiter.timeout);
531
537
  toolWaiter.cleanup?.();
@@ -578,18 +584,22 @@ export class VirtualCodexAgent {
578
584
  this.waiters.delete(turnId);
579
585
  const reply = this.finalMessages.get(turnId)?.trim() || "Codex turn completed without a final message.";
580
586
  for (const waiter of waiters) {
581
- await this.client.send(waiter.from.id, { text: reply, replyTo: waiter.message.id }).catch((error) => {
587
+ await this.client.send(waiter.from.id, { text: reply, replyTo: waiter.message.id, team: waiter.message.content.team }).catch((error) => {
582
588
  process.stderr.write(`reply failed for ${this.agent.id}: ${error instanceof Error ? error.message : String(error)}\n`);
583
589
  });
584
590
  }
585
591
  }
586
592
 
587
- async replyToWaitersFromIntercomSend(turnId: string, send: { to: string; message: string }): Promise<void> {
593
+ async replyToWaitersFromIntercomSend(turnId: string, send: { to: string; message: string; team?: string }): Promise<void> {
588
594
  const waiters = this.waiters.get(turnId);
589
595
  if (!waiters?.length) return;
590
596
  const lowerTo = send.to.toLowerCase();
591
597
  const remaining: TurnWaiter[] = [];
592
598
  for (const waiter of waiters) {
599
+ if (send.team !== undefined && send.team !== waiter.message.content.team) {
600
+ remaining.push(waiter);
601
+ continue;
602
+ }
593
603
  const matchesSender = send.to === waiter.from.id
594
604
  || waiter.from.id.startsWith(send.to)
595
605
  || waiter.from.name?.toLowerCase() === lowerTo;
@@ -597,7 +607,7 @@ export class VirtualCodexAgent {
597
607
  remaining.push(waiter);
598
608
  continue;
599
609
  }
600
- await this.client.send(waiter.from.id, { text: send.message, replyTo: waiter.message.id }).catch((error) => {
610
+ await this.client.send(waiter.from.id, { text: send.message, replyTo: waiter.message.id, team: waiter.message.content.team }).catch((error) => {
601
611
  remaining.push(waiter);
602
612
  process.stderr.write(`reply failed for ${this.agent.id}: ${error instanceof Error ? error.message : String(error)}\n`);
603
613
  });
@@ -664,8 +674,32 @@ export class VirtualCodexAgent {
664
674
  `session_id: ${this.agent.id}\nname: ${this.agent.name}\ncwd: ${this.agent.cwd}`,
665
675
  { session_id: this.agent.id, name: this.agent.name, cwd: this.agent.cwd, model: this.agent.model ?? "codex-app-server" },
666
676
  );
677
+ case "intercom_join": {
678
+ if (args.name === undefined) {
679
+ if (args.create || args.members !== undefined || args.work !== undefined) throw new Error("Creating or extending a team requires a name");
680
+ return textToolResult(formatJoinableNamedTeamList(listNamedTeams()));
681
+ }
682
+ if (args.members !== undefined && !Array.isArray(args.members)) throw new Error("members must be an array");
683
+ const sessions = await this.client.listSessions();
684
+ const members = Array.from((args.members ?? []) as unknown[], (member) => {
685
+ const id = resolveSessionTarget(sessions, asString(member, "member"));
686
+ if (!id) throw new Error("Team member is not connected");
687
+ return id;
688
+ });
689
+ const team = await appendNamedTeamMembership({ name: asString(args.name, "name"), selfId: this.agent.id, members, create: args.create === true, ...(args.work === undefined ? {} : { work: asString(args.work, "work") }) });
690
+ const roster = namedTeamRoster(team, this.agent.id, sessions);
691
+ return textToolResult(formatNamedTeamRoster(roster), { ok: true, team: team.name, roster });
692
+ }
667
693
  case "intercom_team": {
668
694
  const sessions = await this.client.listSessions();
695
+ const mine = sessionNamedTeams(this.agent.id);
696
+ const requested = args.team === undefined ? undefined : asString(args.team, "team");
697
+ const selected = requested ? mine.filter((entry) => entry.name === requested) : mine;
698
+ if (requested && !selected.length) throw new Error("You do not belong to that team");
699
+ if (selected.length) {
700
+ const teams = selected.map((entry) => namedTeamRoster(entry, this.agent.id, sessions));
701
+ return textToolResult(teams.map(formatNamedTeamRoster).join("\n\n"), { teams });
702
+ }
669
703
  const team = await resolveIntercomTeam({ selfId: this.agent.id, sessions });
670
704
  return textToolResult(formatIntercomTeam(team), team as unknown as Record<string, unknown>);
671
705
  }
@@ -692,7 +726,8 @@ export class VirtualCodexAgent {
692
726
  const to = asString(args.to, "to");
693
727
  const message = asString(args.message, "message");
694
728
  const sendTo = await this.resolveTarget(to);
695
- const result = await this.client.send(sendTo, { text: message });
729
+ const team = resolveNamedMessageTeam(this.agent.id, sendTo, args.team === undefined ? undefined : asString(args.team, "team"));
730
+ const result = await this.client.send(sendTo, { text: message, team });
696
731
  if (!result.delivered) {
697
732
  return textToolResult(`Message to "${to}" was not delivered: ${result.reason ?? "Session may not exist or has disconnected."}`, { ok: false, message_id: result.id, reason: result.reason }, true);
698
733
  }
@@ -705,10 +740,11 @@ export class VirtualCodexAgent {
705
740
  const message = asString(args.message, "message");
706
741
  const timeoutMs = asOptionalPositiveInteger(args.timeout_ms, "timeout_ms") ?? DEFAULT_ASK_TIMEOUT_MS;
707
742
  const sendTo = await this.resolveTarget(to);
743
+ const team = resolveNamedMessageTeam(this.agent.id, sendTo, args.team === undefined ? undefined : asString(args.team, "team"));
708
744
  const questionId = randomUUID();
709
- const replyPromise = this.waitForToolReply(sendTo, questionId, timeoutMs, signal);
745
+ const replyPromise = this.waitForToolReply(sendTo, questionId, timeoutMs, signal, team);
710
746
  void replyPromise.catch(() => undefined);
711
- const result = await this.client.send(sendTo, { messageId: questionId, text: message, expectsReply: true });
747
+ const result = await this.client.send(sendTo, { messageId: questionId, text: message, expectsReply: true, team });
712
748
  if (!result.delivered) {
713
749
  this.rejectToolReply(questionId, new Error(result.reason ?? "Session may not exist or has disconnected."));
714
750
  return textToolResult(`Message to "${to}" was not delivered: ${result.reason ?? "Session may not exist or has disconnected."}`, { ok: false, message_id: result.id, reason: result.reason }, true);
@@ -748,7 +784,7 @@ export class VirtualCodexAgent {
748
784
  return resolveSessionTarget(sessions, to) ?? to;
749
785
  }
750
786
 
751
- private waitForToolReply(from: string, replyTo: string, timeoutMs = DEFAULT_ASK_TIMEOUT_MS, signal?: AbortSignal): Promise<Message> {
787
+ private waitForToolReply(from: string, replyTo: string, timeoutMs = DEFAULT_ASK_TIMEOUT_MS, signal?: AbortSignal, team?: string): Promise<Message> {
752
788
  return new Promise((resolve, reject) => {
753
789
  if (signal?.aborted) {
754
790
  reject(new Error("intercom_ask cancelled"));
@@ -772,7 +808,7 @@ export class VirtualCodexAgent {
772
808
  reject(new Error(`No reply from "${from}" within ${Math.round(timeoutMs / 1000)} seconds`));
773
809
  }, timeoutMs);
774
810
  signal?.addEventListener("abort", onAbort, { once: true });
775
- this.toolReplyWaiters.set(replyTo, { from, resolve, reject, timeout, cleanup });
811
+ this.toolReplyWaiters.set(replyTo, { from, team, resolve, reject, timeout, cleanup });
776
812
  });
777
813
  }
778
814
 
@@ -1,5 +1,6 @@
1
1
  import type { CodexIntercomRuntime, 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
 
5
6
  interface JsonRpcRequest {
@@ -34,6 +35,16 @@ function asString(value: unknown, name: string): string {
34
35
  return value;
35
36
  }
36
37
 
38
+ function optionalString(value: unknown, name: string): string | undefined {
39
+ return value === undefined ? undefined : asString(value, name);
40
+ }
41
+
42
+ function memberArray(value: unknown): string[] | undefined {
43
+ if (value === undefined) return undefined;
44
+ if (!Array.isArray(value)) throw new Error("members must be an array of session names or IDs");
45
+ return Array.from(value, (member) => asString(member, "member"));
46
+ }
47
+
37
48
  function asBoolean(value: unknown, defaultValue: boolean): boolean {
38
49
  return typeof value === "boolean" ? value : defaultValue;
39
50
  }
@@ -85,26 +96,30 @@ export function buildToolDefinitions(runtime: CodexIntercomRuntime): ToolDefinit
85
96
  },
86
97
  {
87
98
  name: "intercom_team",
88
- description: "Show your current manager and the live coworkers owned by that manager. No arguments are required.",
89
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
99
+ description: "Show all your named task teams, or inspect one by name. Falls back to managed-team discovery.",
100
+ inputSchema: { type: "object", properties: { team: { type: "string" } }, additionalProperties: false },
90
101
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
91
- handler: async () => runtime.team(),
102
+ handler: async (args) => runtime.team(optionalString(args.team, "team")),
92
103
  },
93
104
  {
94
105
  name: "intercom_join",
95
- 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.",
106
+ 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.",
96
107
  inputSchema: {
97
108
  type: "object",
98
109
  properties: {
99
110
  name: { type: "string", description: "Team name to join. Omit to list joinable teams." },
100
111
  create: { type: "boolean", description: "Create this named team and join as manager. Requires name." },
112
+ members: { type: "array", items: { type: "string" }, description: "Connected session names or IDs to add; manager only." },
113
+ work: { type: "string", maxLength: 2000, description: "Task description when creating a team." },
101
114
  },
102
115
  additionalProperties: false,
103
116
  },
104
117
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
105
118
  handler: async (args) => runtime.join(
106
- typeof args.name === "string" ? args.name : undefined,
119
+ optionalString(args.name, "name"),
107
120
  args.create === true,
121
+ memberArray(args.members),
122
+ optionalString(args.work, "work"),
108
123
  ),
109
124
  },
110
125
  {
@@ -152,12 +167,13 @@ export function buildToolDefinitions(runtime: CodexIntercomRuntime): ToolDefinit
152
167
  to: { type: "string" },
153
168
  message: { type: "string" },
154
169
  attachments: attachmentsSchema,
170
+ team: { type: "string", description: "Task team; required when multiple teams are shared. Omit for ungrouped contact." },
155
171
  },
156
172
  required: ["to", "message"],
157
173
  additionalProperties: false,
158
174
  },
159
175
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
160
- handler: async (args) => runtime.send(asString(args.to, "to"), asString(args.message, "message"), asAttachmentArray(args.attachments)),
176
+ handler: async (args) => runtime.send(asString(args.to, "to"), asString(args.message, "message"), asAttachmentArray(args.attachments), undefined, optionalString(args.team, "team")),
161
177
  },
162
178
  {
163
179
  name: "intercom_ask",
@@ -168,6 +184,7 @@ export function buildToolDefinitions(runtime: CodexIntercomRuntime): ToolDefinit
168
184
  to: { type: "string" },
169
185
  message: { type: "string" },
170
186
  attachments: attachmentsSchema,
187
+ team: { type: "string", description: "Task team; required when multiple teams are shared." },
171
188
  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." },
172
189
  },
173
190
  required: ["to", "message"],
@@ -180,6 +197,7 @@ export function buildToolDefinitions(runtime: CodexIntercomRuntime): ToolDefinit
180
197
  asAttachmentArray(args.attachments),
181
198
  asOptionalPositiveInteger(args.timeout_ms, "timeout_ms"),
182
199
  signal,
200
+ optionalString(args.team, "team"),
183
201
  ),
184
202
  },
185
203
  {
@@ -195,19 +213,22 @@ export function buildToolDefinitions(runtime: CodexIntercomRuntime): ToolDefinit
195
213
  },
196
214
  {
197
215
  name: "intercom_reply",
198
- description: "Reply to a pending inbound ask. Use to plus which=oldest/latest when one sender has multiple unresolved asks.",
216
+ description: "Reply to an inbound ask or ordinary message using askId or contextId from intercom_pending. Inherits the original team; team cannot override it.",
199
217
  inputSchema: {
200
218
  type: "object",
201
219
  properties: {
202
220
  message: { type: "string" },
203
221
  to: { type: "string", description: "Optional sender/session selector; never a message or thread ID." },
222
+ askId: { type: "string", description: "Stable receiver-local pending ask selector." },
223
+ contextId: { type: "string", description: "Exact inbound context selector, including ordinary messages." },
224
+ team: { type: "string", description: "Must match the original message; omit to inherit." },
204
225
  which: { type: "string", enum: ["oldest", "latest"], description: "Select the oldest or latest ask from the chosen sender." },
205
226
  },
206
227
  required: ["message"],
207
228
  additionalProperties: false,
208
229
  },
209
230
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
210
- 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),
231
+ 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")),
211
232
  },
212
233
  ];
213
234
  }
@@ -243,7 +264,8 @@ export async function handleMcpRequest(request: JsonRpcRequest, runtime: CodexIn
243
264
  return ok(request.id, {
244
265
  protocolVersion: "2025-06-18",
245
266
  capabilities: { tools: {} },
246
- serverInfo: { name: "codex-intercom", version: "0.12.2" },
267
+ instructions: TASK_TEAM_GUIDANCE,
268
+ serverInfo: { name: "codex-intercom", version: "0.13.0" },
247
269
  });
248
270
  case "ping":
249
271
  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
 
@@ -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
+ }