@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.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +6 -0
- package/README.md +14 -3
- package/broker/broker.ts +1 -0
- package/broker/client.ts +3 -0
- package/codex/bridge-daemon.ts +51 -15
- package/codex/mcp-protocol.ts +31 -9
- package/codex/named-team-membership.ts +124 -0
- package/codex/named-teams.ts +11 -0
- package/codex/reply-context.ts +49 -0
- package/codex/runtime.ts +44 -30
- package/codex/team-guidance.ts +7 -0
- package/dist/bridge-daemon.mjs +264 -14
- package/dist/broker.mjs +2 -1
- package/dist/build-info.json +2 -2
- package/dist/codex-server.mjs +235 -80
- package/dist/coi.mjs +275 -25
- package/package.json +1 -1
- package/skills/codex-intercom/SKILL.md +5 -5
- package/types.ts +1 -0
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
|
|
190
|
-
- `intercom_join
|
|
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
|
|
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
|
};
|
package/codex/bridge-daemon.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/codex/mcp-protocol.ts
CHANGED
|
@@ -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
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/codex/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
|
|
|
@@ -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
|
+
}
|