@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.
@@ -0,0 +1,124 @@
1
+ import { mkdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { ensureIntercomRuntimeDir, getAgentDirPath, getIntercomDirPath } from "../broker/paths.ts";
5
+ import { writeDurableJson } from "../durable-json.ts";
6
+ import { generateNamedTeamScope, listNamedTeams, NAMED_TEAMS_FILE, NAMED_TEAMS_VERSION, parseTeamName, type NamedTeam } from "./named-teams.ts";
7
+ import type { SessionInfo } from "../types.ts";
8
+
9
+ export function namedTeamMemberIds(team: NamedTeam): string[] {
10
+ return team.memberSessionIds ?? [team.managerSessionId];
11
+ }
12
+
13
+ export function sessionNamedTeams(sessionId: string, agentDir?: string): NamedTeam[] {
14
+ return listNamedTeams(agentDir).filter((team) => namedTeamMemberIds(team).includes(sessionId));
15
+ }
16
+
17
+ /** Serialize the complete read-modify-write across independently launched agent processes. */
18
+ export async function appendNamedTeamMembership(input: {
19
+ name: string;
20
+ selfId: string;
21
+ members?: string[];
22
+ create?: boolean;
23
+ work?: string;
24
+ agentDir?: string;
25
+ }): Promise<NamedTeam> {
26
+ const name = parseTeamName(input.name);
27
+ const memberIds = [...new Set([input.selfId, ...(input.members ?? [])])];
28
+ if (memberIds.some((id) => !id || id.trim() !== id || /[\u0000-\u001f\u007f]/.test(id))) {
29
+ throw new Error("Invalid team member session ID");
30
+ }
31
+ if (input.work !== undefined && (!input.work.trim() || input.work.length > 2000)) {
32
+ throw new Error("Work must be a non-empty task description (max 2000 characters)");
33
+ }
34
+ const dir = getIntercomDirPath(input.agentDir ?? getAgentDirPath());
35
+ ensureIntercomRuntimeDir(dir);
36
+ const lock = join(dir, "named-teams.lock");
37
+ const deadline = Date.now() + 5000;
38
+ for (;;) {
39
+ try {
40
+ mkdirSync(lock, { mode: 0o700 });
41
+ break;
42
+ } catch (error) {
43
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
44
+ if (Date.now() >= deadline) throw new Error("Team registry is busy; retry. If a writer crashed, remove the stale named-teams.lock directory after verifying no writer is active.");
45
+ await delay(25);
46
+ }
47
+ }
48
+ try {
49
+ const teams = listNamedTeams(input.agentDir);
50
+ let team = teams.find((entry) => entry.name === name);
51
+ if (input.create) {
52
+ if (team) throw new Error(`A named team called ${name} already exists; join it instead.`);
53
+ team = { name, scopeId: generateNamedTeamScope(teams.map((entry) => entry.scopeId)), managerSessionId: input.selfId, createdAt: Date.now() };
54
+ teams.push(team);
55
+ }
56
+ if (!team) throw new Error(`Team "${name}" does not exist; create it explicitly.`);
57
+ if (memberIds.some((id) => id !== input.selfId) && team.managerSessionId !== input.selfId) {
58
+ throw new Error(`Only the manager of team "${name}" may add other sessions`);
59
+ }
60
+ if (input.work !== undefined && !input.create) {
61
+ throw new Error("Work is set when creating a team; use a new team for a different task");
62
+ }
63
+ const updated: NamedTeam = {
64
+ ...team,
65
+ memberSessionIds: [...new Set([...namedTeamMemberIds(team), ...memberIds])],
66
+ ...(input.work === undefined ? {} : { work: input.work.trim() }),
67
+ };
68
+ writeDurableJson(join(dir, NAMED_TEAMS_FILE), {
69
+ version: NAMED_TEAMS_VERSION,
70
+ teams: teams.map((entry) => entry.name === name ? updated : entry),
71
+ });
72
+ return updated;
73
+ } finally {
74
+ rmSync(lock, { recursive: true });
75
+ }
76
+ }
77
+
78
+ export function requireNamedTeamMembers(name: string, selfId: string, peerId: string, agentDir?: string): NamedTeam {
79
+ const team = listNamedTeams(agentDir).find((entry) => entry.name === name);
80
+ if (!team) throw new Error(`Unknown team "${name}"`);
81
+ const members = namedTeamMemberIds(team);
82
+ if (!members.includes(selfId) || !members.includes(peerId)) {
83
+ throw new Error(`Both sessions must belong to team "${name}" before messaging`);
84
+ }
85
+ return team;
86
+ }
87
+
88
+ export function resolveNamedMessageTeam(selfId: string, peerId: string, requested?: string, agentDir?: string): string | undefined {
89
+ if (requested !== undefined) return requireNamedTeamMembers(requested, selfId, peerId, agentDir).name;
90
+ const mine = sessionNamedTeams(selfId, agentDir);
91
+ const shared = mine.filter((team) => namedTeamMemberIds(team).includes(peerId));
92
+ if (shared.length === 1) return shared[0]!.name;
93
+ if (shared.length > 1) throw new Error("Multiple shared teams; specify `team` for this task");
94
+ // No shared team means an ungrouped contact, even when either session belongs
95
+ // to unrelated teams. Do not silently create a team or add the recipient.
96
+ return undefined;
97
+ }
98
+
99
+ export function namedTeamRoster(team: NamedTeam, selfId: string, sessions: SessionInfo[]) {
100
+ return {
101
+ name: team.name,
102
+ ...(team.work ? { work: team.work } : {}),
103
+ self: { id: selfId, isManager: selfId === team.managerSessionId },
104
+ manager: { target: team.managerSessionId, connected: sessions.some((entry) => entry.id === team.managerSessionId) },
105
+ members: namedTeamMemberIds(team).map((id) => ({
106
+ id,
107
+ target: id,
108
+ name: sessions.find((entry) => entry.id === id)?.name,
109
+ role: id === team.managerSessionId ? "manager" : "member",
110
+ connected: sessions.some((entry) => entry.id === id),
111
+ })),
112
+ };
113
+ }
114
+
115
+ export function formatNamedTeamRoster(team: ReturnType<typeof namedTeamRoster>): string {
116
+ return [
117
+ `Team: ${team.name}`,
118
+ ...(team.work ? [`Work: ${team.work}`] : []),
119
+ `You: ${team.self.id}${team.self.isManager ? " [manager]" : ""}`,
120
+ `Manager: ${team.manager.target}${team.manager.connected ? "" : " [offline]"}`,
121
+ "Members:",
122
+ ...team.members.map((member) => `- ${member.name || member.id} (${member.id}) [${member.role}]${member.connected ? "" : " [offline]"}`),
123
+ ].join("\n");
124
+ }
@@ -13,6 +13,8 @@ export interface NamedTeam {
13
13
  scopeId: string;
14
14
  managerSessionId: string;
15
15
  createdAt: number;
16
+ memberSessionIds?: string[];
17
+ work?: string;
16
18
  }
17
19
 
18
20
  interface NamedTeamsFile {
@@ -55,11 +57,20 @@ function parseStoredTeam(value: unknown): NamedTeam | undefined {
55
57
  if (typeof value.createdAt !== "number" || !Number.isSafeInteger(value.createdAt) || value.createdAt <= 0) {
56
58
  return undefined;
57
59
  }
60
+ if (value.memberSessionIds !== undefined && (
61
+ !Array.isArray(value.memberSessionIds)
62
+ || !value.memberSessionIds.every((id) => typeof id === "string" && id.trim() === id && id.length > 0 && !/[\u0000-\u001f\u007f]/.test(id))
63
+ || new Set(value.memberSessionIds).size !== value.memberSessionIds.length
64
+ || !value.memberSessionIds.includes(value.managerSessionId)
65
+ )) return undefined;
66
+ if (value.work !== undefined && (typeof value.work !== "string" || !value.work.trim() || value.work.length > 2000)) return undefined;
58
67
  return {
59
68
  name: value.name,
60
69
  scopeId: value.scopeId,
61
70
  managerSessionId: value.managerSessionId,
62
71
  createdAt: value.createdAt,
72
+ ...(value.memberSessionIds === undefined ? {} : { memberSessionIds: value.memberSessionIds as string[] }),
73
+ ...(value.work === undefined ? {} : { work: value.work as string }),
63
74
  };
64
75
  }
65
76
 
@@ -0,0 +1,49 @@
1
+ import type { PendingInboundMessage, ReplyWhich } from "./runtime.ts";
2
+ import { selectPendingAsk } from "./runtime.ts";
3
+
4
+ export function contextId(entry: PendingInboundMessage): string {
5
+ return `ctx-${entry.message.id}`;
6
+ }
7
+
8
+ export function askId(entry: PendingInboundMessage): string {
9
+ return `ask-${entry.message.id}`;
10
+ }
11
+
12
+ export function replyHint(entry: PendingInboundMessage): string {
13
+ const team = entry.message.content.team;
14
+ return `${team ? ` [Team: ${team}]` : ""} [contextId: ${contextId(entry)}]${entry.message.expectsReply ? ` [askId: ${askId(entry)}]` : ""}`;
15
+ }
16
+
17
+ /** Select from the receiver's own retained inbox, never from a mutable current team. */
18
+ export function selectReplyContext(entries: PendingInboundMessage[], asks: PendingInboundMessage[], input: {
19
+ to?: string;
20
+ which?: ReplyWhich;
21
+ askId?: string;
22
+ contextId?: string;
23
+ team?: string;
24
+ }): PendingInboundMessage {
25
+ if (input.askId && input.contextId) throw new Error("Specify askId or contextId, not both");
26
+ let target: PendingInboundMessage;
27
+ if (input.askId || input.contextId) {
28
+ const selected = input.askId
29
+ ? asks.find((entry) => askId(entry) === input.askId)
30
+ : entries.find((entry) => contextId(entry) === input.contextId);
31
+ if (!selected || (selected.message.expectsReply && !asks.includes(selected))) throw new Error("Unknown or resolved reply selector; call intercom_pending");
32
+ if (input.to && selected.from.id !== input.to && selected.from.name?.toLowerCase() !== input.to.toLowerCase()) {
33
+ throw new Error("Reply sender does not match the selected context");
34
+ }
35
+ if (input.which) throw new Error("Do not combine an exact reply selector with which");
36
+ target = selected;
37
+ } else {
38
+ const to = input.to;
39
+ const candidates = to ? asks.filter((entry) => entry.from.id === to || entry.from.name?.toLowerCase() === to.toLowerCase() || entry.from.id.startsWith(to)) : asks;
40
+ if (!input.which && new Set(candidates.map((entry) => entry.message.content.team)).size > 1) {
41
+ throw new Error("Multiple team contexts; specify askId or contextId");
42
+ }
43
+ target = selectPendingAsk(asks, input.to, input.which);
44
+ }
45
+ if (input.team !== undefined && input.team !== target.message.content.team) {
46
+ throw new Error("Reply team must match the original message; omit team to inherit it");
47
+ }
48
+ return target;
49
+ }
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.`;