@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/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 team = findNamedTeam(parseTeamName(name));
368
- if (!team) return textResult("Could not join that team.", { ok: false }, true);
369
- await this.switchRuntimeScope(team.scopeId, team.managerSessionId);
370
- return textResult(formatNamedJoinSuccess({ team: team.name, name: this.identity.name }), { ok: true, team: team.name, role: "teammate" });
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 result = await client.send(sendTo, { text: message, attachments, replyTo });
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 = selectPendingAsk(Array.from(this.unresolvedAsks.values()), to, which);
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.`;
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- process.stderr.write("[agent-intercom-build] package=@ctliz/agent-intercom-codex version=0.12.2 target=bridge-daemon sourceSha256=fb36315f414b3f19538f70942c884026e951ee63fb0060970c2c041e21aed32d\n");
2
+ process.stderr.write("[agent-intercom-build] package=@ctliz/agent-intercom-codex version=0.13.0 target=bridge-daemon sourceSha256=04013522326f3042c72f6454a9c79e26d2d1c8de7af57769ed890e50fca514e3\n");
3
3
 
4
4
  // codex/bridge-daemon.ts
5
5
  import { once } from "node:events";
@@ -1566,6 +1566,7 @@ function isMessage(value) {
1566
1566
  if (typeof content.text !== "string") {
1567
1567
  return false;
1568
1568
  }
1569
+ if (content.team !== void 0 && (typeof content.team !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(content.team))) return false;
1569
1570
  return content.attachments === void 0 || Array.isArray(content.attachments) && content.attachments.every(isAttachment);
1570
1571
  }
1571
1572
  function isSessionInfo(value) {
@@ -2156,6 +2157,7 @@ var IntercomClient = class extends EventEmitter2 {
2156
2157
  expectsReply: options.expectsReply,
2157
2158
  content: {
2158
2159
  text: options.text,
2160
+ ...options.team === void 0 ? {} : { team: options.team },
2159
2161
  attachments: options.attachments
2160
2162
  }
2161
2163
  };
@@ -3064,6 +3066,213 @@ function formatIntercomTeam(team) {
3064
3066
  return lines.join("\n");
3065
3067
  }
3066
3068
 
3069
+ // codex/named-teams.ts
3070
+ import { randomBytes as randomBytes2 } from "node:crypto";
3071
+ import { readFileSync as readFileSync8 } from "node:fs";
3072
+ import { join as join8 } from "node:path";
3073
+ var NAMED_TEAMS_FILE = "named-teams.json";
3074
+ var NAMED_TEAMS_VERSION = 1;
3075
+ var NAMED_TEAM_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
3076
+ function isNamedTeamScope(value) {
3077
+ if (value.length !== 48) return false;
3078
+ for (const char of value) {
3079
+ if (!(char >= "0" && char <= "9" || char >= "a" && char <= "f")) return false;
3080
+ }
3081
+ return true;
3082
+ }
3083
+ function teamsFilePath(agentDir) {
3084
+ return join8(getIntercomDirPath(agentDir ?? getAgentDirPath()), NAMED_TEAMS_FILE);
3085
+ }
3086
+ function genericReadError() {
3087
+ return new Error("Could not read the local named-team list.");
3088
+ }
3089
+ function genericWriteError() {
3090
+ return new Error("Could not create that named team.");
3091
+ }
3092
+ function isPlainObject(value) {
3093
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3094
+ }
3095
+ function parseStoredTeam(value) {
3096
+ if (!isPlainObject(value)) return void 0;
3097
+ if (typeof value.name !== "string" || !NAMED_TEAM_NAME_PATTERN.test(value.name)) return void 0;
3098
+ if (typeof value.scopeId !== "string" || !isNamedTeamScope(value.scopeId)) return void 0;
3099
+ if (typeof value.managerSessionId !== "string" || !value.managerSessionId.trim()) return void 0;
3100
+ if (value.managerSessionId !== value.managerSessionId.trim() || /[\u0000-\u001f\u007f]/.test(value.managerSessionId)) {
3101
+ return void 0;
3102
+ }
3103
+ if (typeof value.createdAt !== "number" || !Number.isSafeInteger(value.createdAt) || value.createdAt <= 0) {
3104
+ return void 0;
3105
+ }
3106
+ if (value.memberSessionIds !== void 0 && (!Array.isArray(value.memberSessionIds) || !value.memberSessionIds.every((id) => typeof id === "string" && id.trim() === id && id.length > 0 && !/[\u0000-\u001f\u007f]/.test(id)) || new Set(value.memberSessionIds).size !== value.memberSessionIds.length || !value.memberSessionIds.includes(value.managerSessionId))) return void 0;
3107
+ if (value.work !== void 0 && (typeof value.work !== "string" || !value.work.trim() || value.work.length > 2e3)) return void 0;
3108
+ return {
3109
+ name: value.name,
3110
+ scopeId: value.scopeId,
3111
+ managerSessionId: value.managerSessionId,
3112
+ createdAt: value.createdAt,
3113
+ ...value.memberSessionIds === void 0 ? {} : { memberSessionIds: value.memberSessionIds },
3114
+ ...value.work === void 0 ? {} : { work: value.work }
3115
+ };
3116
+ }
3117
+ function parseTeamName(raw) {
3118
+ const name = raw.trim();
3119
+ if (!name || name.includes(" ") || !NAMED_TEAM_NAME_PATTERN.test(name)) {
3120
+ throw new Error("Team names start with a letter and may include letters, numbers, hyphens, or underscores (max 32).");
3121
+ }
3122
+ return name;
3123
+ }
3124
+ function generateNamedTeamScope(existing = []) {
3125
+ const taken = new Set(existing);
3126
+ for (let attempt = 0; attempt < 8; attempt += 1) {
3127
+ const scopeId = randomBytes2(24).toString("hex");
3128
+ if (!taken.has(scopeId)) return scopeId;
3129
+ }
3130
+ throw genericWriteError();
3131
+ }
3132
+ function listNamedTeams(agentDir) {
3133
+ let raw;
3134
+ try {
3135
+ raw = readFileSync8(teamsFilePath(agentDir), "utf8");
3136
+ } catch (error) {
3137
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return [];
3138
+ throw genericReadError();
3139
+ }
3140
+ let parsed;
3141
+ try {
3142
+ parsed = JSON.parse(raw);
3143
+ } catch {
3144
+ throw genericReadError();
3145
+ }
3146
+ if (!isPlainObject(parsed) || parsed.version !== NAMED_TEAMS_VERSION || !Array.isArray(parsed.teams)) {
3147
+ throw genericReadError();
3148
+ }
3149
+ const teams = [];
3150
+ const names = /* @__PURE__ */ new Set();
3151
+ const scopes = /* @__PURE__ */ new Set();
3152
+ for (const entry of parsed.teams) {
3153
+ const team = parseStoredTeam(entry);
3154
+ if (!team || names.has(team.name) || scopes.has(team.scopeId)) throw genericReadError();
3155
+ names.add(team.name);
3156
+ scopes.add(team.scopeId);
3157
+ teams.push(team);
3158
+ }
3159
+ return teams;
3160
+ }
3161
+ function formatJoinableNamedTeamList(teams) {
3162
+ if (teams.length === 0) {
3163
+ return 'No joinable named teams found.\nCreate one with intercom_join({ name: "billing", create: true }).';
3164
+ }
3165
+ return [
3166
+ "Joinable named teams:",
3167
+ ...teams.map((team, index) => ` ${index + 1}) ${team.name}`)
3168
+ ].join("\n");
3169
+ }
3170
+
3171
+ // codex/named-team-membership.ts
3172
+ import { mkdirSync as mkdirSync5, rmSync } from "node:fs";
3173
+ import { join as join9 } from "node:path";
3174
+ import { setTimeout as delay2 } from "node:timers/promises";
3175
+ function namedTeamMemberIds(team) {
3176
+ return team.memberSessionIds ?? [team.managerSessionId];
3177
+ }
3178
+ function sessionNamedTeams(sessionId, agentDir) {
3179
+ return listNamedTeams(agentDir).filter((team) => namedTeamMemberIds(team).includes(sessionId));
3180
+ }
3181
+ async function appendNamedTeamMembership(input) {
3182
+ const name = parseTeamName(input.name);
3183
+ const memberIds = [.../* @__PURE__ */ new Set([input.selfId, ...input.members ?? []])];
3184
+ if (memberIds.some((id) => !id || id.trim() !== id || /[\u0000-\u001f\u007f]/.test(id))) {
3185
+ throw new Error("Invalid team member session ID");
3186
+ }
3187
+ if (input.work !== void 0 && (!input.work.trim() || input.work.length > 2e3)) {
3188
+ throw new Error("Work must be a non-empty task description (max 2000 characters)");
3189
+ }
3190
+ const dir = getIntercomDirPath(input.agentDir ?? getAgentDirPath());
3191
+ ensureIntercomRuntimeDir(dir);
3192
+ const lock = join9(dir, "named-teams.lock");
3193
+ const deadline = Date.now() + 5e3;
3194
+ for (; ; ) {
3195
+ try {
3196
+ mkdirSync5(lock, { mode: 448 });
3197
+ break;
3198
+ } catch (error) {
3199
+ if (error.code !== "EEXIST") throw error;
3200
+ 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.");
3201
+ await delay2(25);
3202
+ }
3203
+ }
3204
+ try {
3205
+ const teams = listNamedTeams(input.agentDir);
3206
+ let team = teams.find((entry) => entry.name === name);
3207
+ if (input.create) {
3208
+ if (team) throw new Error(`A named team called ${name} already exists; join it instead.`);
3209
+ team = { name, scopeId: generateNamedTeamScope(teams.map((entry) => entry.scopeId)), managerSessionId: input.selfId, createdAt: Date.now() };
3210
+ teams.push(team);
3211
+ }
3212
+ if (!team) throw new Error(`Team "${name}" does not exist; create it explicitly.`);
3213
+ if (memberIds.some((id) => id !== input.selfId) && team.managerSessionId !== input.selfId) {
3214
+ throw new Error(`Only the manager of team "${name}" may add other sessions`);
3215
+ }
3216
+ if (input.work !== void 0 && !input.create) {
3217
+ throw new Error("Work is set when creating a team; use a new team for a different task");
3218
+ }
3219
+ const updated = {
3220
+ ...team,
3221
+ memberSessionIds: [.../* @__PURE__ */ new Set([...namedTeamMemberIds(team), ...memberIds])],
3222
+ ...input.work === void 0 ? {} : { work: input.work.trim() }
3223
+ };
3224
+ writeDurableJson(join9(dir, NAMED_TEAMS_FILE), {
3225
+ version: NAMED_TEAMS_VERSION,
3226
+ teams: teams.map((entry) => entry.name === name ? updated : entry)
3227
+ });
3228
+ return updated;
3229
+ } finally {
3230
+ rmSync(lock, { recursive: true });
3231
+ }
3232
+ }
3233
+ function requireNamedTeamMembers(name, selfId, peerId, agentDir) {
3234
+ const team = listNamedTeams(agentDir).find((entry) => entry.name === name);
3235
+ if (!team) throw new Error(`Unknown team "${name}"`);
3236
+ const members = namedTeamMemberIds(team);
3237
+ if (!members.includes(selfId) || !members.includes(peerId)) {
3238
+ throw new Error(`Both sessions must belong to team "${name}" before messaging`);
3239
+ }
3240
+ return team;
3241
+ }
3242
+ function resolveNamedMessageTeam(selfId, peerId, requested, agentDir) {
3243
+ if (requested !== void 0) return requireNamedTeamMembers(requested, selfId, peerId, agentDir).name;
3244
+ const mine = sessionNamedTeams(selfId, agentDir);
3245
+ const shared = mine.filter((team) => namedTeamMemberIds(team).includes(peerId));
3246
+ if (shared.length === 1) return shared[0].name;
3247
+ if (shared.length > 1) throw new Error("Multiple shared teams; specify `team` for this task");
3248
+ return void 0;
3249
+ }
3250
+ function namedTeamRoster(team, selfId, sessions) {
3251
+ return {
3252
+ name: team.name,
3253
+ ...team.work ? { work: team.work } : {},
3254
+ self: { id: selfId, isManager: selfId === team.managerSessionId },
3255
+ manager: { target: team.managerSessionId, connected: sessions.some((entry) => entry.id === team.managerSessionId) },
3256
+ members: namedTeamMemberIds(team).map((id) => ({
3257
+ id,
3258
+ target: id,
3259
+ name: sessions.find((entry) => entry.id === id)?.name,
3260
+ role: id === team.managerSessionId ? "manager" : "member",
3261
+ connected: sessions.some((entry) => entry.id === id)
3262
+ }))
3263
+ };
3264
+ }
3265
+ function formatNamedTeamRoster(team) {
3266
+ return [
3267
+ `Team: ${team.name}`,
3268
+ ...team.work ? [`Work: ${team.work}`] : [],
3269
+ `You: ${team.self.id}${team.self.isManager ? " [manager]" : ""}`,
3270
+ `Manager: ${team.manager.target}${team.manager.connected ? "" : " [offline]"}`,
3271
+ "Members:",
3272
+ ...team.members.map((member) => `- ${member.name || member.id} (${member.id}) [${member.role}]${member.connected ? "" : " [offline]"}`)
3273
+ ].join("\n");
3274
+ }
3275
+
3067
3276
  // codex/runtime.ts
3068
3277
  function formatAttachments(attachments) {
3069
3278
  if (!attachments?.length) return "";
@@ -3119,10 +3328,20 @@ function formatSessionList(sessions, currentSessionId, currentCwd) {
3119
3328
  }).join("\n");
3120
3329
  }
3121
3330
 
3331
+ // codex/team-guidance.ts
3332
+ var TASK_TEAM_GUIDANCE = `Intercom task-team rules:
3333
+ - 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.
3334
+ - 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.
3335
+ - 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.
3336
+ - 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.
3337
+ - 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.
3338
+ - 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.`;
3339
+
3122
3340
  // codex/bridge-daemon.ts
3123
3341
  var APPROVED_INTERCOM_TOOLS = /* @__PURE__ */ new Set([
3124
3342
  "intercom_whoami",
3125
3343
  "intercom_team",
3344
+ "intercom_join",
3126
3345
  "intercom_status",
3127
3346
  "intercom_list",
3128
3347
  "intercom_set_summary",
@@ -3156,6 +3375,7 @@ ${agent.instructions}` : "";
3156
3375
  return [
3157
3376
  `Intercom message for ${agent.name}.`,
3158
3377
  `From: ${formatSessionDisplay(from)} (${from.id})`,
3378
+ ...message.content.team ? [`[Team: ${message.content.team}]`] : [],
3159
3379
  `Message id: ${message.id}`,
3160
3380
  "",
3161
3381
  message.content.text,
@@ -3251,7 +3471,7 @@ function intercomSendFromArgs(rawArgs) {
3251
3471
  } catch {
3252
3472
  return null;
3253
3473
  }
3254
- return typeof args.to === "string" && typeof args.message === "string" ? { to: args.to, message: args.message } : null;
3474
+ return typeof args.to === "string" && typeof args.message === "string" ? { to: args.to, message: args.message, ...typeof args.team === "string" ? { team: args.team } : {} } : null;
3255
3475
  }
3256
3476
  function getCompletedIntercomSend(params) {
3257
3477
  if (!params || typeof params !== "object") return null;
@@ -3279,7 +3499,7 @@ function asOptionalPositiveInteger(value, name) {
3279
3499
  return validateAskTimeoutMs(value, name);
3280
3500
  }
3281
3501
  function normalizeToolName(name) {
3282
- const mcpMatch = name.match(/(?:^|__|\.)intercom_(whoami|status|list|set_summary|send|ask|pending|reply)$/);
3502
+ const mcpMatch = name.match(/(?:^|__|\.)intercom_(whoami|team|join|status|list|set_summary|send|ask|pending|reply)$/);
3283
3503
  if (mcpMatch) return `intercom_${mcpMatch[1]}`;
3284
3504
  return name;
3285
3505
  }
@@ -3402,7 +3622,7 @@ var VirtualCodexAgent = class {
3402
3622
  }
3403
3623
  scheduleReconnect() {
3404
3624
  if (!this.reconnectEnabled || this.reconnectTimer) return;
3405
- const delay2 = this.reconnectDelays[Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1)];
3625
+ const delay3 = this.reconnectDelays[Math.min(this.reconnectAttempt, this.reconnectDelays.length - 1)];
3406
3626
  this.reconnectTimer = setTimeout(() => {
3407
3627
  this.reconnectTimer = null;
3408
3628
  void this.connectIntercom().then(() => {
@@ -3416,7 +3636,7 @@ var VirtualCodexAgent = class {
3416
3636
  `);
3417
3637
  this.scheduleReconnect();
3418
3638
  });
3419
- }, delay2);
3639
+ }, delay3);
3420
3640
  this.reconnectTimer.unref?.();
3421
3641
  }
3422
3642
  clearReconnectTimer() {
@@ -3534,7 +3754,7 @@ var VirtualCodexAgent = class {
3534
3754
  approvalPolicy: bridgeAgentApprovalPolicy(this.agent),
3535
3755
  sandbox,
3536
3756
  serviceName: "codex-intercom",
3537
- developerInstructions: this.agent.instructions ?? null,
3757
+ developerInstructions: [this.agent.instructions, TASK_TEAM_GUIDANCE].filter(Boolean).join("\n\n"),
3538
3758
  threadSource: "cli"
3539
3759
  });
3540
3760
  this.threadId = getThreadId(result);
@@ -3547,7 +3767,7 @@ var VirtualCodexAgent = class {
3547
3767
  routeMessage(from, message) {
3548
3768
  const toolWaiter = this.toolReplyWaiters.get(message.replyTo ?? "");
3549
3769
  if (toolWaiter) {
3550
- if (from.id === toolWaiter.from) {
3770
+ if (from.id === toolWaiter.from && message.content.team === toolWaiter.team) {
3551
3771
  this.toolReplyWaiters.delete(message.replyTo ?? "");
3552
3772
  clearTimeout(toolWaiter.timeout);
3553
3773
  toolWaiter.cleanup?.();
@@ -3593,7 +3813,7 @@ var VirtualCodexAgent = class {
3593
3813
  this.waiters.delete(turnId);
3594
3814
  const reply = this.finalMessages.get(turnId)?.trim() || "Codex turn completed without a final message.";
3595
3815
  for (const waiter of waiters) {
3596
- await this.client.send(waiter.from.id, { text: reply, replyTo: waiter.message.id }).catch((error) => {
3816
+ await this.client.send(waiter.from.id, { text: reply, replyTo: waiter.message.id, team: waiter.message.content.team }).catch((error) => {
3597
3817
  process.stderr.write(`reply failed for ${this.agent.id}: ${error instanceof Error ? error.message : String(error)}
3598
3818
  `);
3599
3819
  });
@@ -3605,12 +3825,16 @@ var VirtualCodexAgent = class {
3605
3825
  const lowerTo = send.to.toLowerCase();
3606
3826
  const remaining = [];
3607
3827
  for (const waiter of waiters) {
3828
+ if (send.team !== void 0 && send.team !== waiter.message.content.team) {
3829
+ remaining.push(waiter);
3830
+ continue;
3831
+ }
3608
3832
  const matchesSender = send.to === waiter.from.id || waiter.from.id.startsWith(send.to) || waiter.from.name?.toLowerCase() === lowerTo;
3609
3833
  if (!matchesSender) {
3610
3834
  remaining.push(waiter);
3611
3835
  continue;
3612
3836
  }
3613
- await this.client.send(waiter.from.id, { text: send.message, replyTo: waiter.message.id }).catch((error) => {
3837
+ await this.client.send(waiter.from.id, { text: send.message, replyTo: waiter.message.id, team: waiter.message.content.team }).catch((error) => {
3614
3838
  remaining.push(waiter);
3615
3839
  process.stderr.write(`reply failed for ${this.agent.id}: ${error instanceof Error ? error.message : String(error)}
3616
3840
  `);
@@ -3675,8 +3899,32 @@ name: ${this.agent.name}
3675
3899
  cwd: ${this.agent.cwd}`,
3676
3900
  { session_id: this.agent.id, name: this.agent.name, cwd: this.agent.cwd, model: this.agent.model ?? "codex-app-server" }
3677
3901
  );
3902
+ case "intercom_join": {
3903
+ if (args.name === void 0) {
3904
+ if (args.create || args.members !== void 0 || args.work !== void 0) throw new Error("Creating or extending a team requires a name");
3905
+ return textToolResult(formatJoinableNamedTeamList(listNamedTeams()));
3906
+ }
3907
+ if (args.members !== void 0 && !Array.isArray(args.members)) throw new Error("members must be an array");
3908
+ const sessions = await this.client.listSessions();
3909
+ const members = Array.from(args.members ?? [], (member) => {
3910
+ const id = resolveSessionTarget(sessions, asString(member, "member"));
3911
+ if (!id) throw new Error("Team member is not connected");
3912
+ return id;
3913
+ });
3914
+ const team = await appendNamedTeamMembership({ name: asString(args.name, "name"), selfId: this.agent.id, members, create: args.create === true, ...args.work === void 0 ? {} : { work: asString(args.work, "work") } });
3915
+ const roster = namedTeamRoster(team, this.agent.id, sessions);
3916
+ return textToolResult(formatNamedTeamRoster(roster), { ok: true, team: team.name, roster });
3917
+ }
3678
3918
  case "intercom_team": {
3679
3919
  const sessions = await this.client.listSessions();
3920
+ const mine = sessionNamedTeams(this.agent.id);
3921
+ const requested = args.team === void 0 ? void 0 : asString(args.team, "team");
3922
+ const selected = requested ? mine.filter((entry) => entry.name === requested) : mine;
3923
+ if (requested && !selected.length) throw new Error("You do not belong to that team");
3924
+ if (selected.length) {
3925
+ const teams = selected.map((entry) => namedTeamRoster(entry, this.agent.id, sessions));
3926
+ return textToolResult(teams.map(formatNamedTeamRoster).join("\n\n"), { teams });
3927
+ }
3680
3928
  const team = await resolveIntercomTeam({ selfId: this.agent.id, sessions });
3681
3929
  return textToolResult(formatIntercomTeam(team), team);
3682
3930
  }
@@ -3705,7 +3953,8 @@ Active sessions: ${sessions.length}`,
3705
3953
  const to = asString(args.to, "to");
3706
3954
  const message = asString(args.message, "message");
3707
3955
  const sendTo = await this.resolveTarget(to);
3708
- const result = await this.client.send(sendTo, { text: message });
3956
+ const team = resolveNamedMessageTeam(this.agent.id, sendTo, args.team === void 0 ? void 0 : asString(args.team, "team"));
3957
+ const result = await this.client.send(sendTo, { text: message, team });
3709
3958
  if (!result.delivered) {
3710
3959
  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);
3711
3960
  }
@@ -3718,10 +3967,11 @@ Active sessions: ${sessions.length}`,
3718
3967
  const message = asString(args.message, "message");
3719
3968
  const timeoutMs = asOptionalPositiveInteger(args.timeout_ms, "timeout_ms") ?? DEFAULT_ASK_TIMEOUT_MS;
3720
3969
  const sendTo = await this.resolveTarget(to);
3970
+ const team = resolveNamedMessageTeam(this.agent.id, sendTo, args.team === void 0 ? void 0 : asString(args.team, "team"));
3721
3971
  const questionId = randomUUID4();
3722
- const replyPromise = this.waitForToolReply(sendTo, questionId, timeoutMs, signal);
3972
+ const replyPromise = this.waitForToolReply(sendTo, questionId, timeoutMs, signal, team);
3723
3973
  void replyPromise.catch(() => void 0);
3724
- const result = await this.client.send(sendTo, { messageId: questionId, text: message, expectsReply: true });
3974
+ const result = await this.client.send(sendTo, { messageId: questionId, text: message, expectsReply: true, team });
3725
3975
  if (!result.delivered) {
3726
3976
  this.rejectToolReply(questionId, new Error(result.reason ?? "Session may not exist or has disconnected."));
3727
3977
  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);
@@ -3757,7 +4007,7 @@ ${reply.content.text}${formatAttachments(reply.content.attachments)}`, { ok: tru
3757
4007
  const sessions = await this.client.listSessions();
3758
4008
  return resolveSessionTarget(sessions, to) ?? to;
3759
4009
  }
3760
- waitForToolReply(from, replyTo, timeoutMs = DEFAULT_ASK_TIMEOUT_MS, signal) {
4010
+ waitForToolReply(from, replyTo, timeoutMs = DEFAULT_ASK_TIMEOUT_MS, signal, team) {
3761
4011
  return new Promise((resolve4, reject) => {
3762
4012
  if (signal?.aborted) {
3763
4013
  reject(new Error("intercom_ask cancelled"));
@@ -3781,7 +4031,7 @@ ${reply.content.text}${formatAttachments(reply.content.attachments)}`, { ok: tru
3781
4031
  reject(new Error(`No reply from "${from}" within ${Math.round(timeoutMs / 1e3)} seconds`));
3782
4032
  }, timeoutMs);
3783
4033
  signal?.addEventListener("abort", onAbort, { once: true });
3784
- this.toolReplyWaiters.set(replyTo, { from, resolve: resolve4, reject, timeout, cleanup });
4034
+ this.toolReplyWaiters.set(replyTo, { from, team, resolve: resolve4, reject, timeout, cleanup });
3785
4035
  });
3786
4036
  }
3787
4037
  rejectToolReply(replyTo, error) {
package/dist/broker.mjs CHANGED
@@ -1,4 +1,4 @@
1
- process.stderr.write("[agent-intercom-build] package=@ctliz/agent-intercom-codex version=0.12.2 target=broker sourceSha256=fb36315f414b3f19538f70942c884026e951ee63fb0060970c2c041e21aed32d\n");
1
+ process.stderr.write("[agent-intercom-build] package=@ctliz/agent-intercom-codex version=0.13.0 target=broker sourceSha256=04013522326f3042c72f6454a9c79e26d2d1c8de7af57769ed890e50fca514e3\n");
2
2
 
3
3
  // broker/broker.ts
4
4
  import net from "net";
@@ -1291,6 +1291,7 @@ function isMessage(value) {
1291
1291
  if (typeof content.text !== "string" || Buffer.byteLength(content.text, "utf-8") > MAX_MESSAGE_TEXT_BYTES) {
1292
1292
  return false;
1293
1293
  }
1294
+ if (content.team !== void 0 && (typeof content.team !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(content.team))) return false;
1294
1295
  return content.attachments === void 0 || Array.isArray(content.attachments) && content.attachments.length <= MAX_ATTACHMENTS && content.attachments.every(isAttachment);
1295
1296
  }
1296
1297
  function isSessionId(value) {
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "@ctliz/agent-intercom-codex",
4
- "version": "0.12.2",
5
- "sourceSha256": "fb36315f414b3f19538f70942c884026e951ee63fb0060970c2c041e21aed32d",
4
+ "version": "0.13.0",
5
+ "sourceSha256": "04013522326f3042c72f6454a9c79e26d2d1c8de7af57769ed890e50fca514e3",
6
6
  "targets": [
7
7
  "codex-server",
8
8
  "broker",