@bastani/atomic 0.9.9-alpha.2 → 0.9.9-alpha.3

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/builtin/cursor/CHANGELOG.md +6 -0
  3. package/dist/builtin/cursor/package.json +2 -2
  4. package/dist/builtin/intercom/CHANGELOG.md +7 -0
  5. package/dist/builtin/intercom/README.md +10 -3
  6. package/dist/builtin/intercom/broker/client.ts +2 -0
  7. package/dist/builtin/intercom/broker/send-handler.ts +22 -20
  8. package/dist/builtin/intercom/foreground-detach-handoff.ts +3 -1
  9. package/dist/builtin/intercom/inbound-idle-queue.ts +82 -0
  10. package/dist/builtin/intercom/index-heavy.ts +47 -37
  11. package/dist/builtin/intercom/intercom-tool.ts +6 -4
  12. package/dist/builtin/intercom/intercom-utils.ts +30 -0
  13. package/dist/builtin/intercom/lifecycle.ts +8 -5
  14. package/dist/builtin/intercom/package.json +1 -1
  15. package/dist/builtin/intercom/reply-tracker.ts +19 -19
  16. package/dist/builtin/intercom/session-target.ts +68 -0
  17. package/dist/builtin/intercom/skills/intercom/SKILL.md +7 -4
  18. package/dist/builtin/intercom/source-ownership.ts +34 -0
  19. package/dist/builtin/intercom/subagent-relay.ts +45 -20
  20. package/dist/builtin/intercom/terminal-ordering-barrier.ts +111 -0
  21. package/dist/builtin/intercom/types.ts +5 -0
  22. package/dist/builtin/mcp/CHANGELOG.md +6 -0
  23. package/dist/builtin/mcp/package.json +1 -1
  24. package/dist/builtin/subagents/CHANGELOG.md +6 -0
  25. package/dist/builtin/subagents/package.json +1 -1
  26. package/dist/builtin/subagents/src/runs/background/notify.ts +53 -9
  27. package/dist/builtin/subagents/src/shared/types-config.ts +1 -0
  28. package/dist/builtin/web-access/CHANGELOG.md +6 -0
  29. package/dist/builtin/web-access/package.json +1 -1
  30. package/dist/builtin/workflows/CHANGELOG.md +6 -0
  31. package/dist/builtin/workflows/package.json +1 -1
  32. package/dist/builtin/workflows/src/extension/workflow-tool.ts +18 -2
  33. package/dist/core/agent-session-extension-bindings.d.ts.map +1 -1
  34. package/dist/core/agent-session-extension-bindings.js +9 -0
  35. package/dist/core/agent-session-extension-bindings.js.map +1 -1
  36. package/dist/core/agent-session-message-queue.d.ts +4 -1
  37. package/dist/core/agent-session-message-queue.d.ts.map +1 -1
  38. package/dist/core/agent-session-message-queue.js +35 -0
  39. package/dist/core/agent-session-message-queue.js.map +1 -1
  40. package/dist/core/agent-session-methods.d.ts +2 -1
  41. package/dist/core/agent-session-methods.d.ts.map +1 -1
  42. package/dist/core/agent-session-methods.js.map +1 -1
  43. package/dist/core/extensions/api-types.d.ts +3 -1
  44. package/dist/core/extensions/api-types.d.ts.map +1 -1
  45. package/dist/core/extensions/api-types.js.map +1 -1
  46. package/dist/core/extensions/index.d.ts +1 -1
  47. package/dist/core/extensions/index.d.ts.map +1 -1
  48. package/dist/core/extensions/index.js.map +1 -1
  49. package/dist/core/extensions/loader-api.d.ts.map +1 -1
  50. package/dist/core/extensions/loader-api.js +4 -0
  51. package/dist/core/extensions/loader-api.js.map +1 -1
  52. package/dist/core/extensions/loader-runtime.d.ts.map +1 -1
  53. package/dist/core/extensions/loader-runtime.js +1 -0
  54. package/dist/core/extensions/loader-runtime.js.map +1 -1
  55. package/dist/core/extensions/message-types.d.ts +3 -0
  56. package/dist/core/extensions/message-types.d.ts.map +1 -1
  57. package/dist/core/extensions/message-types.js.map +1 -1
  58. package/dist/core/extensions/runner.d.ts.map +1 -1
  59. package/dist/core/extensions/runner.js +1 -0
  60. package/dist/core/extensions/runner.js.map +1 -1
  61. package/dist/core/extensions/runtime-types.d.ts +3 -1
  62. package/dist/core/extensions/runtime-types.d.ts.map +1 -1
  63. package/dist/core/extensions/runtime-types.js.map +1 -1
  64. package/docs/extensions.md +13 -0
  65. package/docs/subagents.md +2 -0
  66. package/docs/workflows.md +2 -2
  67. package/npm-shrinkwrap.json +23 -23
  68. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.9-alpha.3] - 2026-07-14
6
+
7
+ ### Added
8
+
9
+ - Added `pi.sendMessages()` for atomic, array-ordered custom-message admission without waiting for the resulting model turn, allowing companion extensions to keep related preludes and terminal notices contiguous without globally serializing unrelated work.
10
+
11
+ ### Fixed
12
+
13
+ - Preserved accepted async-child Intercom chronology across lazily activated companion extensions by atomically admitting same-child ordinary messages before pause, completion, and failure notices; unrelated children remain independent and ask/reply behavior is unchanged ([#1802](https://github.com/bastani-inc/atomic/issues/1802)).
14
+
5
15
  ## [0.9.9-alpha.2] - 2026-07-14
6
16
 
7
17
  ### Changed
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.9-alpha.3] - 2026-07-14
6
+
7
+ ### Changed
8
+
9
+ - Published a synchronized Atomic 0.9.9-alpha.3 prerelease for the Cursor provider package; no functional Cursor provider changes were made after 0.9.9-alpha.2.
10
+
5
11
  ## [0.9.9-alpha.2] - 2026-07-14
6
12
 
7
13
  ### Changed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/cursor",
3
- "version": "0.9.9-alpha.2",
3
+ "version": "0.9.9-alpha.3",
4
4
  "private": true,
5
5
  "description": "Experimental first-party Atomic extension for Cursor OAuth, model discovery, and streaming provider registration.",
6
6
  "contributors": [
@@ -40,7 +40,7 @@
40
40
  }
41
41
  },
42
42
  "dependencies": {
43
- "@bastani/atomic-natives": "0.9.9-alpha.2",
43
+ "@bastani/atomic-natives": "0.9.9-alpha.3",
44
44
  "@bufbuild/protobuf": "^2.12.1",
45
45
  "@earendil-works/pi-ai": "^0.80.7"
46
46
  }
@@ -4,6 +4,13 @@ All notable changes to the `pi-intercom` extension will be documented in this fi
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.9-alpha.3] - 2026-07-14
8
+
9
+ ### Fixed
10
+
11
+ - Preserved per-child chronology between busy-parent Intercom messages and terminal async subagent notifications: paused, completed, and failed paths now claim pre-terminal ordinary messages from the exact child session/run in FIFO order and atomically admit the prelude plus terminal notice through the custom-message batch API. A process-local bridge complements extension-bus delivery for lazily activated companions; terminal identity deduplicates both paths even when the successful terminal dispatch has an empty prelude, while failed dispatches remain retryable and distinct resumed-run lifecycle terminals remain independent. Idle flushes use the same ordered batch admission, unrelated children remain independent, and ask/reply correlation is unchanged ([#1802](https://github.com/bastani-inc/atomic/issues/1802)).
12
+ - Made every short session ID printed by `intercom list` directly actionable in `send`, blocking `ask`, and targeted `reply`. Target resolution now preserves exact full IDs and exact case-insensitive names before accepting unique ID prefixes, reports colliding prefixes with the matching sessions instead of guessing, resolves blocking asks to the full sender ID so threaded replies correlate correctly, and rejects short-form self-targets at both tool and broker routing boundaries.
13
+
7
14
  ## [0.9.9-alpha.2] - 2026-07-14
8
15
 
9
16
  ### Changed
@@ -86,6 +86,9 @@ intercom({ action: "list" })
86
86
  intercom({ action: "send", to: "research", message: "Check if UserService.validate() handles null" })
87
87
  // → Message sent to research
88
88
 
89
+ // The short ID printed by list is also a valid target
90
+ intercom({ action: "ask", to: "6332faab", message: "Which validation path should I use?" })
91
+
89
92
  // Check connection status
90
93
  intercom({ action: "status" })
91
94
  // → Connected: Yes, Session ID: abc123, Active sessions: 3
@@ -119,6 +122,8 @@ See auth.ts:142-156.
119
122
 
120
123
  The reply hint (enabled by default) points to `intercom({ action: "reply", ... })`, so recipients do not need raw sender or `replyTo` IDs. Idle recipients get a new turn immediately; busy interactive recipients receive the message once they go idle. Attachment content is included in the agent-visible body, and messages are rendered inline and stored in Pi session history.
121
124
 
125
+ For delegated background children, queued messages and terminal lifecycle notices are ordered per child. Intercom claims the terminal child’s pre-terminal ordinary entries in FIFO order and atomically admits that prelude together with the paused, completed, or failed notice. A process-local companion bridge covers lazily loaded extensions whose event buses are distinct, while exact terminal-identity deduplication prevents double admission even when the successful terminal dispatch has no queued prelude. Failed dispatches remain retryable, and pause/resume/completion identities remain distinct. Other children’s entries remain independently queued, messages are not discarded, terminal admission does not wait for a separate model turn, and correlated ask replies still bypass unrelated queued sends.
126
+
122
127
  ## Workflow: Planner-Worker Coordination
123
128
 
124
129
  The most natural use of pi-intercom is splitting a task between two sessions — one holds the big picture, the other does the hands-on work. When the worker hits an ambiguity ("should I optimize for readability or performance here?"), they ask without losing context.
@@ -317,7 +322,7 @@ The supervisor can reply with plain JSON or a fenced `json` block. If the reply
317
322
  | Parameter | Type | Description |
318
323
  |-----------|------|-------------|
319
324
  | `action` | string | `"list"`, `"send"`, `"ask"`, `"reply"`, `"pending"`, or `"status"` |
320
- | `to` | string | Target session name or ID (for send/ask, or to disambiguate reply) |
325
+ | `to` | string | Exact session name, exact full ID, or unique ID prefix (for send/ask, or to disambiguate reply) |
321
326
  | `message` | string | Message text (for send/ask/reply) |
322
327
  | `attachments` | array | Optional `file`, `snippet`, or `context` attachments |
323
328
  | `replyTo` | string | Optional message ID for threading or replying to an `ask` |
@@ -340,13 +345,15 @@ Only registered in sessions where `pi-subagents` supplied the required child bri
340
345
 
341
346
  ### intercom actions
342
347
 
343
- **`list`** — Returns the current session plus other active intercom-connected sessions with name, short ID, working directory, model, and live status. Status is derived automatically from Pi lifecycle events: `idle`, `thinking`, or `tool:<name>`.
348
+ **`list`** — Returns the current session plus other active intercom-connected sessions with name, short ID, working directory, model, and live status. Every displayed short ID can be passed directly to `send`, `ask`, or targeted `reply`. Status is derived automatically from Pi lifecycle events: `idle`, `thinking`, or `tool:<name>`.
349
+
350
+ Target lookup preserves exact full IDs and exact case-insensitive names, then accepts a unique session-ID prefix. If a prefix matches multiple sessions, Intercom reports every match and asks for a longer ID or exact name instead of guessing. Resolving a prefix to the current session still triggers the normal self-target rejection.
344
351
 
345
352
  **`send`** — Sends a message to the specified session. By default it sends immediately, including in interactive sessions. Set `confirmSend: true` in config if you want a confirmation dialog for non-reply sends. Replies that include `replyTo` skip confirmation. Returns delivery confirmation.
346
353
 
347
354
  **`ask`** — Sends a message and waits for the recipient to reply (10-minute timeout). The reply is returned as the tool result. No confirmation dialog. Only one pending `ask` is allowed per session at a time; if several blocking requests race (parallel `ask` calls, or `ask` alongside `contact_supervisor`), one wins the reservation and each other call returns a normal "Already waiting for a reply" tool error without disturbing the pending ask. Use this when the agent needs the answer to continue working.
348
355
 
349
- **`reply`** — Replies to the current intercom-triggered message if there is one. Otherwise it falls back to the single unresolved inbound ask. If multiple asks are pending, pass `to` or inspect them with `pending` first. Under the hood this is still a normal `send` with the exact `replyTo` value.
356
+ **`reply`** — Replies to the current intercom-triggered message if there is one. Otherwise it falls back to the single unresolved inbound ask. If multiple asks are pending, pass an exact name, exact full ID, or unique ID prefix in `to`, or inspect them with `pending` first. Under the hood this is still a normal `send` with the exact `replyTo` value.
350
357
 
351
358
  **`pending`** — Lists unresolved inbound asks with sender, message ID, elapsed time, and a short preview. Useful when replying after the original triggered turn.
352
359
 
@@ -5,6 +5,7 @@ import { writeMessage, createMessageReader } from "./framing.js";
5
5
  import { getBrokerSocketPath } from "./paths.js";
6
6
  import type { SessionInfo, Message, Attachment } from "../types.js";
7
7
  import { buildSendSignature, PendingSendRegistry } from "./pending-send-registry.js";
8
+ import { readSubagentMessageSource } from "../source-ownership.js";
8
9
 
9
10
  const BROKER_SOCKET = getBrokerSocketPath();
10
11
 
@@ -453,6 +454,7 @@ export class IntercomClient extends EventEmitter {
453
454
  timestamp: Date.now(),
454
455
  replyTo: options.replyTo,
455
456
  expectsReply: options.expectsReply,
457
+ source: readSubagentMessageSource(),
456
458
  content: { text: options.text, attachments: options.attachments },
457
459
  };
458
460
  try {
@@ -1,5 +1,6 @@
1
1
  import type net from "node:net";
2
2
  import type { Attachment, BrokerMessage, Message, SessionInfo } from "../types.js";
3
+ import { resolveSessionTarget, sessionTargetFailureReason } from "../session-target.js";
3
4
  import { DeliveredMessageCache } from "./delivered-message-cache.js";
4
5
  import { buildMessageSendSignature } from "./send-signature.js";
5
6
 
@@ -32,13 +33,6 @@ function isMessage(value: unknown): value is Message {
32
33
  return content.attachments === undefined || (Array.isArray(content.attachments) && content.attachments.every(isAttachment));
33
34
  }
34
35
 
35
- function findSessions(sessions: Map<string, BrokerConnectedSession>, nameOrId: string): BrokerConnectedSession[] {
36
- const byId = sessions.get(nameOrId);
37
- if (byId) return [byId];
38
- const lowerName = nameOrId.toLowerCase();
39
- return Array.from(sessions.values()).filter((session) => session.info.name?.toLowerCase() === lowerName);
40
- }
41
-
42
36
  /** Validate and route one wire-level send request. */
43
37
  export function handleBrokerSend(
44
38
  socket: net.Socket,
@@ -81,26 +75,34 @@ export function handleBrokerSend(
81
75
  return;
82
76
  }
83
77
 
84
- const targets = findSessions(sessions, clientMessage.to);
85
- if (targets.length === 1) {
78
+ const resolution = resolveSessionTarget(
79
+ Array.from(sessions.values(), (session) => session.info),
80
+ clientMessage.to,
81
+ );
82
+ if (resolution.kind === "resolved") {
83
+ const target = sessions.get(resolution.session.id);
86
84
  const fromSession = currentId ? sessions.get(currentId) : undefined;
87
85
  if (!fromSession) {
88
86
  write(socket, { type: "delivery_failed", messageId: message.id, attemptId, reason: "Sender session not found" });
89
87
  return;
90
88
  }
91
- write(targets[0].socket, { type: "message", from: fromSession.info, message });
89
+ if (!target) {
90
+ write(socket, { type: "delivery_failed", messageId: message.id, attemptId, reason: "Session not found" });
91
+ return;
92
+ }
93
+ if (target.info.id === fromSession.info.id) {
94
+ write(socket, { type: "delivery_failed", messageId: message.id, attemptId, reason: "Cannot message the current session" });
95
+ return;
96
+ }
97
+ write(target.socket, { type: "message", from: fromSession.info, message });
92
98
  deliveredMessages.record(message.id, signature);
93
99
  write(socket, { type: "delivered", messageId: message.id, attemptId });
94
100
  return;
95
101
  }
96
- if (targets.length > 1) {
97
- write(socket, {
98
- type: "delivery_failed",
99
- messageId: message.id,
100
- attemptId,
101
- reason: `Multiple sessions named \"${clientMessage.to}\" are connected. Use the session ID instead.`,
102
- });
103
- return;
104
- }
105
- write(socket, { type: "delivery_failed", messageId: message.id, attemptId, reason: "Session not found" });
102
+ write(socket, {
103
+ type: "delivery_failed",
104
+ messageId: message.id,
105
+ attemptId,
106
+ reason: sessionTargetFailureReason(clientMessage.to, resolution),
107
+ });
106
108
  }
@@ -130,7 +130,9 @@ export async function handleForegroundInboundDelivery(input: {
130
130
  surface: () => void;
131
131
  isCurrent: () => boolean;
132
132
  onUnclaimed: () => void;
133
+ onDelivered?: () => void;
133
134
  }): Promise<void> {
134
135
  const disposition = await input.handoff.deliver(input);
135
- if (disposition === "unclaimed" && input.isCurrent()) input.onUnclaimed();
136
+ if (disposition === "delivered") input.onDelivered?.();
137
+ else if (disposition === "unclaimed" && input.isCurrent()) input.onUnclaimed();
136
138
  }
@@ -0,0 +1,82 @@
1
+ import type { InboundMessageEntry } from "./intercom-utils.js";
2
+
3
+ export interface InboundIdleClaim {
4
+ entries: InboundMessageEntry[];
5
+ rollbackFrom(index: number): void;
6
+ }
7
+
8
+ /**
9
+ * FIFO ownership for ordinary inbound messages accepted while the parent is
10
+ * busy. A terminal barrier may claim only entries from its exact child session
11
+ * target; unrelated children retain their original queue positions.
12
+ */
13
+ export class InboundIdleQueue {
14
+ private entries: InboundMessageEntry[] = [];
15
+
16
+ get size(): number {
17
+ return this.entries.length;
18
+ }
19
+
20
+ enqueue(entry: InboundMessageEntry): void {
21
+ this.entries.push(entry);
22
+ }
23
+
24
+ has(entry: InboundMessageEntry): boolean {
25
+ return this.entries.includes(entry);
26
+ }
27
+
28
+ remove(entry: InboundMessageEntry): boolean {
29
+ const index = this.entries.indexOf(entry);
30
+ if (index < 0) return false;
31
+ this.entries.splice(index, 1);
32
+ return true;
33
+ }
34
+
35
+ drain(): InboundMessageEntry[] {
36
+ const entries = this.entries;
37
+ this.entries = [];
38
+ return entries;
39
+ }
40
+
41
+ claimOrdinarySourceTargets(runId: string, sourceSessionTargets: readonly string[], terminalAt = Number.POSITIVE_INFINITY): InboundIdleClaim {
42
+ const targets = new Set(sourceSessionTargets);
43
+ const original = this.entries;
44
+ const legacyIdsByName = new Map<string, Set<string>>();
45
+ for (const entry of original) {
46
+ if (entry.message.source?.subagentRunId !== undefined || entry.from.name === undefined) continue;
47
+ const ids = legacyIdsByName.get(entry.from.name) ?? new Set<string>();
48
+ ids.add(entry.from.id);
49
+ legacyIdsByName.set(entry.from.name, ids);
50
+ }
51
+ const selected = original.filter((entry) => {
52
+ if (entry.message.expectsReply) return false;
53
+ if (entry.message.timestamp > terminalAt) return false;
54
+ const idMatches = targets.has(entry.from.id);
55
+ const nameMatches = entry.from.name !== undefined && targets.has(entry.from.name);
56
+ if (!idMatches && !nameMatches) return false;
57
+ const sourceRunId = entry.message.source?.subagentRunId;
58
+ if (sourceRunId !== undefined) return sourceRunId === runId;
59
+ return idMatches || (entry.from.name !== undefined && legacyIdsByName.get(entry.from.name)?.size === 1);
60
+ });
61
+ const selectedSet = new Set(selected);
62
+ const originalSet = new Set(original);
63
+ this.entries = original.filter((entry) => !selectedSet.has(entry));
64
+ let settled = false;
65
+ return {
66
+ entries: selected,
67
+ rollbackFrom: (index) => {
68
+ if (settled) return;
69
+ settled = true;
70
+ const undelivered = new Set(selected.slice(index));
71
+ const laterEntries = this.entries.filter((entry) => !originalSet.has(entry));
72
+ this.entries = original
73
+ .filter((entry) => !selectedSet.has(entry) || undelivered.has(entry))
74
+ .concat(laterEntries);
75
+ },
76
+ };
77
+ }
78
+
79
+ clear(): void {
80
+ this.entries = [];
81
+ }
82
+ }
@@ -15,6 +15,9 @@ import { registerSubagentRelay } from "./subagent-relay.js";
15
15
  import { ForegroundDetachHandoff, handleForegroundInboundDelivery } from "./foreground-detach-handoff.js";
16
16
  import { routeIncomingReply } from "./reply-routing.js";
17
17
  import { INBOUND_FLUSH_DELAY_MS, INBOUND_IDLE_RETRY_MS, type InboundMessageEntry, buildPresenceIdentity, formatAttachments, readChildOrchestratorMetadata, toError } from "./intercom-utils.js";
18
+ import { InboundIdleQueue } from "./inbound-idle-queue.js";
19
+ import { registerTerminalOrderingBarrier } from "./terminal-ordering-barrier.js";
20
+ import { resolveSessionTargetId } from "./session-target.js";
18
21
  if (process.env.ATOMIC_TEST_LAZY_IMPORT_SENTINEL === "1") {
19
22
  process.env.ATOMIC_INTERCOM_HEAVY_IMPORTED = "1";
20
23
  }
@@ -48,7 +51,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
48
51
  const replyTracker = new ReplyTracker();
49
52
  const replyWaiters = new ReplyWaiterSlot();
50
53
  const foregroundDetachHandoff = new ForegroundDetachHandoff(pi);
51
- const pendingIdleMessages: InboundMessageEntry[] = [];
54
+ const pendingIdleMessages = new InboundIdleQueue();
52
55
  let inboundFlushTimer: NodeJS.Timeout | null = null;
53
56
  function rejectReplyWaiter(error: Error): void {
54
57
  replyWaiters.rejectCurrent(error);
@@ -137,25 +140,39 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
137
140
  return Boolean(resolvedTo && activeClient?.sessionId && resolvedTo === activeClient.sessionId)
138
141
  || targets.has(to.trim().toLowerCase());
139
142
  }
140
- function sendIncomingMessage(entry: InboundMessageEntry, delivery: "trigger" | "followUp", generation = runtimeGeneration): void {
143
+ function buildIncomingCustomMessage(entry: InboundMessageEntry) {
144
+ const senderDisplay = entry.from.name || entry.from.id.slice(0, 8);
145
+ const replyInstruction = entry.replyCommand ? `\n\nTo reply, use the intercom tool: ${entry.replyCommand}` : "";
146
+ return {
147
+ customType: "intercom_message" as const,
148
+ content: `**📨 From ${senderDisplay}** (${entry.from.cwd})${replyInstruction}\n\n${entry.bodyText}`,
149
+ display: true as const,
150
+ details: entry,
151
+ };
152
+ }
153
+ function sendIncomingMessage(entry: InboundMessageEntry, delivery: "trigger" | "followUp" | "prelude", generation = runtimeGeneration): void {
141
154
  if (runtimeStarted && !getLiveContext(runtimeContext, generation)) {
142
155
  return;
143
156
  }
144
157
  if (delivery === "trigger") {
145
158
  replyTracker.queueTurnContext({ from: entry.from, message: entry.message, receivedAt: Date.now() });
146
159
  }
147
- const senderDisplay = entry.from.name || entry.from.id.slice(0, 8);
148
- const replyInstruction = entry.replyCommand ? `\n\nTo reply, use the intercom tool: ${entry.replyCommand}` : "";
149
- pi.sendMessage(
150
- {
151
- customType: "intercom_message",
152
- content: `**📨 From ${senderDisplay}** (${entry.from.cwd})${replyInstruction}\n\n${entry.bodyText}`,
153
- display: true,
154
- details: entry,
155
- },
156
- delivery === "trigger" ? { triggerTurn: true } : { deliverAs: "followUp" },
157
- );
160
+ const options = delivery === "trigger"
161
+ ? { triggerTurn: true } as const
162
+ : delivery === "followUp" ? { deliverAs: "followUp" } as const : undefined;
163
+ pi.sendMessage(buildIncomingCustomMessage(entry), options);
158
164
  }
165
+ registerTerminalOrderingBarrier(pi, {
166
+ queue: pendingIdleMessages,
167
+ toMessage: buildIncomingCustomMessage,
168
+ // A prelude is admitted synchronously when idle, or FIFO-queued when busy.
169
+ // The following terminal trigger therefore sees it in context first without
170
+ // waiting for a separate ordinary-message model turn to complete.
171
+ deliver: (entry) => sendIncomingMessage(entry, "prelude"),
172
+ onDrain: () => {
173
+ if (pendingIdleMessages.size === 0) clearInboundFlushTimer();
174
+ },
175
+ });
159
176
  function scheduleInboundFlush(delayMs = INBOUND_FLUSH_DELAY_MS): void {
160
177
  if (!getLiveContext()) {
161
178
  return;
@@ -168,7 +185,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
168
185
  }, delayMs);
169
186
  }
170
187
  function flushIdleMessages(generation = runtimeGeneration): void {
171
- if (pendingIdleMessages.length === 0) {
188
+ if (pendingIdleMessages.size === 0) {
172
189
  return;
173
190
  }
174
191
  const ctx = getLiveContext(runtimeContext, generation);
@@ -187,10 +204,12 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
187
204
  return;
188
205
  }
189
206
 
190
- const entries = pendingIdleMessages.splice(0, pendingIdleMessages.length);
191
- entries.forEach((entry, index) => {
192
- sendIncomingMessage(entry, index === 0 ? "trigger" : "followUp");
193
- });
207
+ const entries = pendingIdleMessages.drain();
208
+ const first = entries[0];
209
+ if (first) replyTracker.queueTurnContext({ from: first.from, message: first.message, receivedAt: Date.now() });
210
+ const messages = entries.map(buildIncomingCustomMessage);
211
+ if (typeof pi.sendMessages === "function") pi.sendMessages(messages, { triggerTurn: true });
212
+ else messages.forEach((message, index) => pi.sendMessage(message, index === 0 ? { triggerTurn: true } : { deliverAs: "followUp" }));
194
213
  }
195
214
  function handleIncomingMessage(ctx: ExtensionContext, from: SessionInfo, message: Message): void {
196
215
  const messageGeneration = runtimeGeneration;
@@ -231,19 +250,24 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
231
250
  }
232
251
  return;
233
252
  }
253
+ // Establish queue ownership before probing asynchronously. If a terminal
254
+ // barrier wins the race, the later foreground callback cannot redeliver.
255
+ pendingIdleMessages.enqueue(entry);
234
256
  await handleForegroundInboundDelivery({
235
257
  handoff: foregroundDetachHandoff,
236
258
  from,
237
259
  message,
238
260
  generation: messageGeneration,
239
- surface: () => sendIncomingMessage(entry, "trigger", messageGeneration),
261
+ surface: () => {
262
+ if (pendingIdleMessages.remove(entry)) sendIncomingMessage(entry, "trigger", messageGeneration);
263
+ },
240
264
  isCurrent: () => Boolean(getLiveContext(liveContext, messageGeneration)),
241
265
  onUnclaimed: () => {
242
266
  // No exact foreground owner acknowledged the target. Preserve the
243
267
  // established background/cross-session behavior by waiting for idle.
244
- pendingIdleMessages.push(entry);
245
- scheduleInboundFlush(INBOUND_IDLE_RETRY_MS);
268
+ if (pendingIdleMessages.has(entry)) scheduleInboundFlush(INBOUND_IDLE_RETRY_MS);
246
269
  },
270
+ onDelivered: () => { pendingIdleMessages.remove(entry); },
247
271
  });
248
272
  return;
249
273
  }
@@ -345,19 +369,6 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
345
369
  reconnectPromiseGeneration = generationAtStart;
346
370
  return nextReconnectPromise;
347
371
  }
348
- async function resolveSessionTarget(activeClient: IntercomClient, nameOrId: string): Promise<string | null> {
349
- const sessions = await activeClient.listSessions();
350
- const byId = sessions.find(s => s.id === nameOrId);
351
- if (byId) {
352
- return byId.id;
353
- }
354
- const lowerName = nameOrId.toLowerCase();
355
- const byName = sessions.filter(s => s.name?.toLowerCase() === lowerName);
356
- if (byName.length > 1) {
357
- throw new Error(`Multiple sessions named "${nameOrId}" are connected. Use the session ID instead.`);
358
- }
359
- return byName[0]?.id ?? null;
360
- }
361
372
  registerSubagentRelay(pi, {
362
373
  runtimeGeneration: () => runtimeGeneration,
363
374
  runtimeStarted: () => runtimeStarted,
@@ -366,7 +377,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
366
377
  currentSessionTargetMatches,
367
378
  sendIncomingMessage,
368
379
  ensureConnected,
369
- resolveSessionTarget,
380
+ resolveSessionTarget: resolveSessionTargetId,
370
381
  });
371
382
 
372
383
  registerIntercomLifecycle(pi, {
@@ -408,14 +419,13 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
408
419
  childOrchestratorMetadata,
409
420
  ensureConnected,
410
421
  syncPresenceIdentity,
411
- resolveSessionTarget,
422
+ resolveSessionTarget: resolveSessionTargetId,
412
423
  beginReplyWait: (from, replyTo, signal) => replyWaiters.begin(from, replyTo, signal),
413
424
  hasReplyWaiter: () => replyWaiters.has(),
414
425
  });
415
426
  registerIntercomTool(pi, {
416
427
  ensureConnected,
417
428
  syncPresenceIdentity,
418
- resolveSessionTarget,
419
429
  beginReplyWait: (from, replyTo, signal) => replyWaiters.begin(from, replyTo, signal),
420
430
  confirmSend: config.confirmSend,
421
431
  replyTracker,
@@ -13,11 +13,12 @@ import {
13
13
  toError,
14
14
  } from "./intercom-utils.js";
15
15
  import type { ReplyTracker } from "./reply-tracker.ts";
16
+ import { resolveSessionTargetId } from "./session-target.js";
16
17
 
17
18
  interface IntercomToolDeps {
18
19
  ensureConnected(reason: "tool"): Promise<IntercomClient>;
19
20
  syncPresenceIdentity(sessionId: string): void;
20
- resolveSessionTarget(activeClient: IntercomClient, nameOrId: string): Promise<string | null>;
21
+ resolveSessionTarget?(activeClient: IntercomClient, nameOrId: string): Promise<string | null>;
21
22
  confirmSend: boolean;
22
23
  /**
23
24
  * Atomically reserve the single reply-waiter slot. Returns a structured
@@ -31,7 +32,8 @@ interface IntercomToolDeps {
31
32
  }
32
33
 
33
34
  export function registerIntercomTool(pi: ExtensionAPI, deps: IntercomToolDeps): void {
34
- const { ensureConnected, syncPresenceIdentity, resolveSessionTarget, beginReplyWait, replyTracker, hasReplyWaiter } = deps;
35
+ const { ensureConnected, syncPresenceIdentity, beginReplyWait, replyTracker, hasReplyWaiter } = deps;
36
+ const resolveTarget = deps.resolveSessionTarget ?? resolveSessionTargetId;
35
37
  pi.registerTool({
36
38
  name: "intercom",
37
39
  label: "Intercom",
@@ -129,7 +131,7 @@ Usage:
129
131
  };
130
132
  }
131
133
  try {
132
- const sendTo = await resolveSessionTarget(connectedClient, to) ?? to;
134
+ const sendTo = await resolveTarget(connectedClient, to) ?? to;
133
135
  if (sendTo === connectedClient.sessionId) {
134
136
  return {
135
137
  content: [{ type: "text", text: "Cannot message the current session" }],
@@ -214,7 +216,7 @@ Usage:
214
216
  let wait: ReplyWait | null = null;
215
217
 
216
218
  try {
217
- const sendTo = await resolveSessionTarget(connectedClient, to) ?? to;
219
+ const sendTo = await resolveTarget(connectedClient, to) ?? to;
218
220
  if (_signal?.aborted) {
219
221
  return {
220
222
  content: [{ type: "text", text: "Cancelled" }],
@@ -6,6 +6,7 @@ import type { Attachment, Message, SessionInfo } from "./types.ts";
6
6
  export const SUBAGENT_CONTROL_INTERCOM_EVENT = "subagent:control-intercom";
7
7
  export const SUBAGENT_RESULT_INTERCOM_EVENT = "subagent:result-intercom";
8
8
  export const SUBAGENT_RESULT_INTERCOM_DELIVERY_EVENT = "subagent:result-intercom-delivery";
9
+ export const SUBAGENT_TERMINAL_ORDERING_BARRIER_EVENT = "subagent:terminal-ordering-barrier";
9
10
  export const INBOUND_FLUSH_DELAY_MS = 200;
10
11
  export const INBOUND_IDLE_RETRY_MS = 500;
11
12
  export const DEFAULT_UNNAMED_SESSION_ALIAS_PREFIX = "subagent-chat";
@@ -361,6 +362,35 @@ export function parseSubagentIntercomPayload(payload: unknown): { to: string; me
361
362
  const requestId = typeof record.requestId === "string" ? record.requestId : undefined;
362
363
  return { to: record.to, message: record.message, ...(requestId ? { requestId } : {}) };
363
364
  }
365
+ function subagentTargetPart(value: string): string {
366
+ return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
367
+ }
368
+
369
+ function resolveSubagentSourceTarget(runId: string, agent: string, index: number): string {
370
+ return `subagent-${subagentTargetPart(agent)}-${subagentTargetPart(runId)}-${index + 1}`;
371
+ }
372
+
373
+ export function parseSubagentResultBarrier(payload: unknown): { runId: string; terminalId?: string; sourceSessionTargets: string[] } | null {
374
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
375
+ const record = payload as Record<string, unknown>;
376
+ if (typeof record.runId !== "string" || !Array.isArray(record.children)) return null;
377
+ const runId = record.runId;
378
+ const requestId = typeof record.requestId === "string" ? record.requestId : undefined;
379
+ const terminalId = requestId?.startsWith("completion-") ? requestId.slice("completion-".length) : requestId;
380
+ const sourceSessionTargets = record.children.flatMap((child, arrayIndex) => {
381
+ if (!child || typeof child !== "object" || Array.isArray(child)) return [];
382
+ const childRecord = child as Record<string, unknown>;
383
+ const target = typeof childRecord.intercomTarget === "string" ? childRecord.intercomTarget.trim() : "";
384
+ if (target) return [target];
385
+ if (typeof childRecord.agent !== "string") return [];
386
+ const index = typeof childRecord.index === "number" && Number.isInteger(childRecord.index) && childRecord.index >= 0
387
+ ? childRecord.index : arrayIndex;
388
+ return [resolveSubagentSourceTarget(runId, childRecord.agent, index)];
389
+ });
390
+ return sourceSessionTargets.length > 0
391
+ ? { runId, ...(terminalId ? { terminalId } : {}), sourceSessionTargets }
392
+ : null;
393
+ }
364
394
  export function resolveIntercomPresenceName(sessionName: string | undefined, sessionId: string): string {
365
395
  const trimmedName = sessionName?.trim();
366
396
  if (trimmedName) {
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@bastani/atomic";
2
2
  import type { IntercomClient } from "./broker/client.ts";
3
3
  import type { IntercomConfig } from "./config.ts";
4
- import { buildPresenceIdentity, type InboundMessageEntry } from "./intercom-utils.js";
4
+ import { buildPresenceIdentity } from "./intercom-utils.js";
5
+ import type { InboundIdleQueue } from "./inbound-idle-queue.js";
5
6
  import type { ReplyTracker } from "./reply-tracker.ts";
6
7
 
7
8
  interface LifecycleDeps {
@@ -23,7 +24,7 @@ interface LifecycleDeps {
23
24
  getLiveContext(ctx?: ExtensionContext | null, generation?: number): ExtensionContext | null;
24
25
  rejectReplyWaiter(error: Error): void;
25
26
  replyTracker: ReplyTracker;
26
- pendingIdleMessages: InboundMessageEntry[];
27
+ pendingIdleMessages: InboundIdleQueue;
27
28
  clearInboundFlushTimer(): void;
28
29
  scheduleInboundFlush(delayMs?: number): void;
29
30
  syncPresenceStatus(): void;
@@ -43,7 +44,7 @@ export function registerIntercomLifecycle(pi: ExtensionAPI, deps: LifecycleDeps)
43
44
  deps.clearReconnectTimer();
44
45
  deps.rejectReplyWaiter(new Error(reason));
45
46
  deps.replyTracker.reset();
46
- deps.pendingIdleMessages.length = 0;
47
+ deps.pendingIdleMessages.clear();
47
48
  deps.clearInboundFlushTimer();
48
49
  deps.setAgentRunning(false);
49
50
  deps.activeTools.clear();
@@ -89,7 +90,9 @@ export function registerIntercomLifecycle(pi: ExtensionAPI, deps: LifecycleDeps)
89
90
  pi.on("turn_end", () => {
90
91
  if (!deps.getLiveContext()) return;
91
92
  deps.replyTracker.endTurn();
92
- deps.scheduleInboundFlush(0);
93
+ // Preserve the normal grace period so a same-tick terminal barrier can
94
+ // claim accepted child messages before idle delivery releases ownership.
95
+ deps.scheduleInboundFlush();
93
96
  });
94
97
  pi.on("agent_start", () => {
95
98
  if (!deps.getLiveContext()) return;
@@ -112,7 +115,7 @@ export function registerIntercomLifecycle(pi: ExtensionAPI, deps: LifecycleDeps)
112
115
  deps.setAgentRunning(false);
113
116
  deps.activeTools.clear();
114
117
  deps.syncPresenceStatus();
115
- deps.scheduleInboundFlush(0);
118
+ deps.scheduleInboundFlush();
116
119
  });
117
120
  pi.on("turn_start", (_event, ctx) => {
118
121
  if (!deps.getLiveContext(ctx)) return;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/intercom",
3
- "version": "0.9.9-alpha.2",
3
+ "version": "0.9.9-alpha.3",
4
4
  "private": true,
5
5
  "description": "Atomic extension providing a private coordination channel between parent and child agent sessions. Fork of: https://github.com/nicobailon/pi-intercom",
6
6
  "contributors": [