@ctliz/agent-intercom-codex 0.12.1 → 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 +134 -0
- package/README.md +20 -3
- package/broker/broker.ts +1 -0
- package/broker/client.ts +3 -0
- package/codex/bridge-daemon.ts +66 -16
- 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 +279 -15
- package/dist/broker.mjs +2 -1
- package/dist/build-info.json +2 -2
- package/dist/codex-server.mjs +235 -80
- package/dist/coi.mjs +290 -26
- package/package.json +2 -1
- package/skills/codex-intercom/SKILL.md +5 -5
- package/types.ts +1 -0
|
@@ -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
|
+
}
|
package/codex/runtime.ts
CHANGED
|
@@ -9,15 +9,14 @@ import { getAskTimeoutMs, loadConfig } from "../config.ts";
|
|
|
9
9
|
import type { Attachment, Message, SessionInfo } from "../types.ts";
|
|
10
10
|
import { formatIntercomTeam, resolveIntercomTeam } from "./team.ts";
|
|
11
11
|
import {
|
|
12
|
-
createNamedTeam,
|
|
13
|
-
findNamedTeam,
|
|
14
12
|
formatCreateSuccess,
|
|
15
13
|
formatJoinableNamedTeamList,
|
|
16
14
|
formatNamedJoinSuccess,
|
|
17
15
|
listNamedTeams,
|
|
18
16
|
parseTeamName,
|
|
19
|
-
rejectManagedJoin,
|
|
20
17
|
} from "./named-teams.ts";
|
|
18
|
+
import { appendNamedTeamMembership, sessionNamedTeams, namedTeamRoster, formatNamedTeamRoster, resolveNamedMessageTeam } from "./named-team-membership.ts";
|
|
19
|
+
import { contextId, askId, replyHint, selectReplyContext } from "./reply-context.ts";
|
|
21
20
|
|
|
22
21
|
export interface CodexRuntimeIdentity {
|
|
23
22
|
sessionId: string;
|
|
@@ -77,6 +76,9 @@ function publicPendingEntry(entry: PendingInboundMessage, selector?: string): Re
|
|
|
77
76
|
...(entry.from.parentSessionId ? { parent_session_id: entry.from.parentSessionId } : {}),
|
|
78
77
|
...(entry.from.generation ? { generation: entry.from.generation } : {}),
|
|
79
78
|
},
|
|
79
|
+
contextId: contextId(entry),
|
|
80
|
+
...(entry.message.expectsReply ? { askId: askId(entry) } : {}),
|
|
81
|
+
...(entry.message.content.team ? { team: entry.message.content.team } : {}),
|
|
80
82
|
received_at: entry.receivedAt,
|
|
81
83
|
read: entry.read,
|
|
82
84
|
text: entry.message.content.text,
|
|
@@ -95,6 +97,7 @@ export interface ToolResult {
|
|
|
95
97
|
interface ReplyWaiter {
|
|
96
98
|
from: string;
|
|
97
99
|
replyTo: string;
|
|
100
|
+
team?: string;
|
|
98
101
|
resolve: (message: Message) => void;
|
|
99
102
|
reject: (error: Error) => void;
|
|
100
103
|
timeout: NodeJS.Timeout;
|
|
@@ -349,25 +352,23 @@ export class CodexIntercomRuntime {
|
|
|
349
352
|
await this.connect();
|
|
350
353
|
}
|
|
351
354
|
|
|
352
|
-
async join(name?: string, create = false): Promise<ToolResult> {
|
|
353
|
-
const blocked = rejectManagedJoin();
|
|
354
|
-
if (blocked) return textResult(blocked, { ok: false }, true);
|
|
355
|
+
async join(name?: string, create = false, members?: string[], work?: string): Promise<ToolResult> {
|
|
355
356
|
try {
|
|
356
|
-
if (create) {
|
|
357
|
-
if (typeof name !== "string" || !name.trim()) {
|
|
358
|
-
return textResult("Creating a team requires a name.", { ok: false }, true);
|
|
359
|
-
}
|
|
360
|
-
const team = createNamedTeam({ name: parseTeamName(name), managerSessionId: this.identity.sessionId });
|
|
361
|
-
await this.switchRuntimeScope(team.scopeId, team.managerSessionId);
|
|
362
|
-
return textResult(formatCreateSuccess({ team: team.name, name: this.identity.name }), { ok: true, team: team.name, role: "manager" });
|
|
363
|
-
}
|
|
364
357
|
if (!name?.trim()) {
|
|
358
|
+
if (create || members?.length || work !== undefined) throw new Error("Creating or extending a team requires a name");
|
|
365
359
|
return textResult(formatJoinableNamedTeamList(listNamedTeams()));
|
|
366
360
|
}
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
361
|
+
const client = await this.connect();
|
|
362
|
+
const sessions = await client.listSessions();
|
|
363
|
+
const memberIds = (members ?? []).map((member) => {
|
|
364
|
+
const id = resolveSessionTarget(sessions, member);
|
|
365
|
+
if (!id) throw new Error(`Session "${member}" is not connected`);
|
|
366
|
+
return id;
|
|
367
|
+
});
|
|
368
|
+
const team = await appendNamedTeamMembership({ name: parseTeamName(name), selfId: this.identity.sessionId, members: memberIds, create, work });
|
|
369
|
+
const roster = namedTeamRoster(team, this.identity.sessionId, sessions);
|
|
370
|
+
const notice = create ? formatCreateSuccess({ team: team.name, name: this.identity.name }) : formatNamedJoinSuccess({ team: team.name, name: this.identity.name });
|
|
371
|
+
return textResult(`${notice}\nOther team memberships are unchanged.`, { ok: true, team: team.name, role: roster.self.isManager ? "manager" : "member", roster });
|
|
371
372
|
} catch (error) {
|
|
372
373
|
return textResult(error instanceof Error ? error.message : String(error), { ok: false }, true);
|
|
373
374
|
}
|
|
@@ -378,7 +379,7 @@ export class CodexIntercomRuntime {
|
|
|
378
379
|
if (waiter) {
|
|
379
380
|
const senderTarget = from.name || from.id;
|
|
380
381
|
const fromMatches = senderTarget.toLowerCase() === waiter.from.toLowerCase() || from.id === waiter.from;
|
|
381
|
-
if (fromMatches) {
|
|
382
|
+
if (fromMatches && message.content.team === waiter.team) {
|
|
382
383
|
this.replyWaiters.delete(waiter.replyTo);
|
|
383
384
|
clearTimeout(waiter.timeout);
|
|
384
385
|
waiter.cleanup?.();
|
|
@@ -394,7 +395,7 @@ export class CodexIntercomRuntime {
|
|
|
394
395
|
}
|
|
395
396
|
}
|
|
396
397
|
|
|
397
|
-
private waitForReply(from: string, replyTo: string, timeoutMs = getAskTimeoutMs(), signal?: AbortSignal): Promise<Message> {
|
|
398
|
+
private waitForReply(from: string, replyTo: string, timeoutMs = getAskTimeoutMs(), signal?: AbortSignal, team?: string): Promise<Message> {
|
|
398
399
|
return new Promise((resolve, reject) => {
|
|
399
400
|
if (signal?.aborted) {
|
|
400
401
|
reject(new Error("intercom_ask cancelled"));
|
|
@@ -418,7 +419,7 @@ export class CodexIntercomRuntime {
|
|
|
418
419
|
reject(new Error(`No reply from "${from}" within ${Math.round(timeoutMs / 1000)} seconds`));
|
|
419
420
|
}, timeoutMs);
|
|
420
421
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
421
|
-
this.replyWaiters.set(replyTo, { from, replyTo, resolve, reject, timeout, cleanup });
|
|
422
|
+
this.replyWaiters.set(replyTo, { from, replyTo, team, resolve, reject, timeout, cleanup });
|
|
422
423
|
});
|
|
423
424
|
}
|
|
424
425
|
|
|
@@ -437,9 +438,16 @@ export class CodexIntercomRuntime {
|
|
|
437
438
|
);
|
|
438
439
|
}
|
|
439
440
|
|
|
440
|
-
async team(): Promise<ToolResult> {
|
|
441
|
+
async team(name?: string): Promise<ToolResult> {
|
|
441
442
|
const client = await this.connect();
|
|
442
443
|
const sessions = await client.listSessions();
|
|
444
|
+
const mine = sessionNamedTeams(this.identity.sessionId);
|
|
445
|
+
const selected = name ? mine.filter((entry) => entry.name === name) : mine;
|
|
446
|
+
if (name && !selected.length) return textResult(`You do not belong to team "${name}"`, { ok: false }, true);
|
|
447
|
+
if (selected.length) {
|
|
448
|
+
const teams = selected.map((entry) => namedTeamRoster(entry, this.identity.sessionId, sessions));
|
|
449
|
+
return textResult(teams.map(formatNamedTeamRoster).join("\n\n"), { teams });
|
|
450
|
+
}
|
|
443
451
|
const team = await resolveIntercomTeam({ selfId: client.sessionId ?? this.identity.sessionId, sessions });
|
|
444
452
|
return textResult(formatIntercomTeam(team), team as unknown as Record<string, unknown>);
|
|
445
453
|
}
|
|
@@ -482,10 +490,14 @@ export class CodexIntercomRuntime {
|
|
|
482
490
|
return textResult("Summary updated.", { ok: true, summary });
|
|
483
491
|
}
|
|
484
492
|
|
|
485
|
-
async send(to: string, message: string, attachments?: Attachment[], replyTo?: string): Promise<ToolResult> {
|
|
493
|
+
async send(to: string, message: string, attachments?: Attachment[], replyTo?: string, requestedTeam?: string): Promise<ToolResult> {
|
|
486
494
|
const client = await this.connect();
|
|
487
495
|
const sendTo = await this.resolveTarget(to);
|
|
488
|
-
const
|
|
496
|
+
const source = replyTo ? this.unread.find((entry) => entry.message.id === replyTo && entry.from.id === sendTo) : undefined;
|
|
497
|
+
if (replyTo && !source) throw new Error("Unknown inbound reply context");
|
|
498
|
+
if (source && requestedTeam !== undefined && requestedTeam !== source.message.content.team) throw new Error("Reply team must match the original message");
|
|
499
|
+
const team = source ? source.message.content.team : resolveNamedMessageTeam(this.identity.sessionId, sendTo, requestedTeam);
|
|
500
|
+
const result = await client.send(sendTo, { text: message, attachments, replyTo: source?.message.expectsReply ? replyTo : undefined, team });
|
|
489
501
|
if (!result.delivered) {
|
|
490
502
|
return textResult(`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);
|
|
491
503
|
}
|
|
@@ -493,11 +505,12 @@ export class CodexIntercomRuntime {
|
|
|
493
505
|
return textResult(`Message sent to ${to}.`, { ok: true, message_id: result.id, to });
|
|
494
506
|
}
|
|
495
507
|
|
|
496
|
-
async ask(to: string, message: string, attachments?: Attachment[], timeoutMs = getAskTimeoutMs(), signal?: AbortSignal): Promise<ToolResult> {
|
|
508
|
+
async ask(to: string, message: string, attachments?: Attachment[], timeoutMs = getAskTimeoutMs(), signal?: AbortSignal, requestedTeam?: string): Promise<ToolResult> {
|
|
497
509
|
const client = await this.connect();
|
|
498
510
|
const sendTo = await this.resolveTarget(to);
|
|
511
|
+
const team = resolveNamedMessageTeam(this.identity.sessionId, sendTo, requestedTeam);
|
|
499
512
|
const questionId = randomUUID();
|
|
500
|
-
const replyPromise = this.waitForReply(sendTo, questionId, timeoutMs, signal);
|
|
513
|
+
const replyPromise = this.waitForReply(sendTo, questionId, timeoutMs, signal, team);
|
|
501
514
|
void replyPromise.catch(() => undefined);
|
|
502
515
|
try {
|
|
503
516
|
const result = await client.send(sendTo, {
|
|
@@ -505,6 +518,7 @@ export class CodexIntercomRuntime {
|
|
|
505
518
|
text: message,
|
|
506
519
|
attachments,
|
|
507
520
|
expectsReply: true,
|
|
521
|
+
team,
|
|
508
522
|
});
|
|
509
523
|
if (!result.delivered) {
|
|
510
524
|
this.replyWaiters.get(questionId)?.reject(new Error(result.reason ?? "Session may not exist or has disconnected."));
|
|
@@ -529,12 +543,12 @@ export class CodexIntercomRuntime {
|
|
|
529
543
|
const pendingAsks = Array.from(this.unresolvedAsks.values()).sort((a, b) => a.receivedAt - b.receivedAt);
|
|
530
544
|
const lines = [
|
|
531
545
|
unreadMessages.length
|
|
532
|
-
? unreadMessages.map((entry) => `- ${formatSessionDisplay(entry.from)}: ${entry.message.content.text}${formatAttachments(entry.message.content.attachments)}`).join("\n")
|
|
546
|
+
? unreadMessages.map((entry) => `- ${formatSessionDisplay(entry.from)}${replyHint(entry)}: ${entry.message.content.text}${formatAttachments(entry.message.content.attachments)}`).join("\n")
|
|
533
547
|
: "No unread messages.",
|
|
534
548
|
pendingAsks.length
|
|
535
549
|
? `\nPending asks:\n${pendingAsks.map((entry) => {
|
|
536
550
|
const selector = pendingSelector(pendingAsks, entry);
|
|
537
|
-
return `- ${formatSessionDisplay(entry.from)}${selector ? ` [${selector}]` : ""}: ${entry.message.content.text}`;
|
|
551
|
+
return `- ${formatSessionDisplay(entry.from)}${replyHint(entry)}${selector ? ` [${selector}]` : ""}: ${entry.message.content.text}`;
|
|
538
552
|
}).join("\n")}`
|
|
539
553
|
: "",
|
|
540
554
|
].filter(Boolean);
|
|
@@ -544,10 +558,10 @@ export class CodexIntercomRuntime {
|
|
|
544
558
|
});
|
|
545
559
|
}
|
|
546
560
|
|
|
547
|
-
async reply(message: string, to?: string, which?: ReplyWhich): Promise<ToolResult> {
|
|
561
|
+
async reply(message: string, to?: string, which?: ReplyWhich, askId?: string, contextId?: string, team?: string): Promise<ToolResult> {
|
|
548
562
|
let target: PendingInboundMessage;
|
|
549
563
|
try {
|
|
550
|
-
target =
|
|
564
|
+
target = selectReplyContext(this.unread, Array.from(this.unresolvedAsks.values()), { to, which, askId, contextId, team });
|
|
551
565
|
} catch (error) {
|
|
552
566
|
return textResult(error instanceof Error ? error.message : String(error), { ok: false }, true);
|
|
553
567
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const TASK_TEAM_GUIDANCE = `Intercom task-team rules:
|
|
2
|
+
- When the user delegates to named peers and this task has no approved team, ask once whether to form a team with you and those peers. Wait for approval before creating a team or adding peers. An explicit create/join request is approval; never ask again for an approved task or inbound team message.
|
|
3
|
+
- After approval, discover the connected peers, then intercom_join({ name: "launch", create: true, members: ["front", "writer"], work: "Current task" }) adds everyone in one call. Do not ask the user to join each terminal manually.
|
|
4
|
+
- Membership is additive: joining another team preserves previous teams. Reuse the approved team for the same task; a different task may need a different team.
|
|
5
|
+
- Initial contact without a shared team is allowed: omit team for an ungrouped direct message. Unrelated team memberships must not prevent contact or silently create teams.
|
|
6
|
+
- Include team on task sends/asks, especially when peers share multiple teams. Replies inherit the original message's team via askId or contextId from intercom_pending; never override it using a current team or mix contexts from different tasks.
|
|
7
|
+
- Use intercom_send for assignments, progress/status requests, notifications and follow-ups. Use intercom_ask only when your next step genuinely depends on the answer; keep only one unresolved ask per recipient.`;
|