@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.
- package/CHANGELOG.md +10 -0
- package/dist/builtin/cursor/CHANGELOG.md +6 -0
- package/dist/builtin/cursor/package.json +2 -2
- package/dist/builtin/intercom/CHANGELOG.md +7 -0
- package/dist/builtin/intercom/README.md +10 -3
- package/dist/builtin/intercom/broker/client.ts +2 -0
- package/dist/builtin/intercom/broker/send-handler.ts +22 -20
- package/dist/builtin/intercom/foreground-detach-handoff.ts +3 -1
- package/dist/builtin/intercom/inbound-idle-queue.ts +82 -0
- package/dist/builtin/intercom/index-heavy.ts +47 -37
- package/dist/builtin/intercom/intercom-tool.ts +6 -4
- package/dist/builtin/intercom/intercom-utils.ts +30 -0
- package/dist/builtin/intercom/lifecycle.ts +8 -5
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/intercom/reply-tracker.ts +19 -19
- package/dist/builtin/intercom/session-target.ts +68 -0
- package/dist/builtin/intercom/skills/intercom/SKILL.md +7 -4
- package/dist/builtin/intercom/source-ownership.ts +34 -0
- package/dist/builtin/intercom/subagent-relay.ts +45 -20
- package/dist/builtin/intercom/terminal-ordering-barrier.ts +111 -0
- package/dist/builtin/intercom/types.ts +5 -0
- package/dist/builtin/mcp/CHANGELOG.md +6 -0
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/CHANGELOG.md +6 -0
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/subagents/src/runs/background/notify.ts +53 -9
- package/dist/builtin/subagents/src/shared/types-config.ts +1 -0
- package/dist/builtin/web-access/CHANGELOG.md +6 -0
- package/dist/builtin/web-access/package.json +1 -1
- package/dist/builtin/workflows/CHANGELOG.md +6 -0
- package/dist/builtin/workflows/package.json +1 -1
- package/dist/builtin/workflows/src/extension/workflow-tool.ts +18 -2
- package/dist/core/agent-session-extension-bindings.d.ts.map +1 -1
- package/dist/core/agent-session-extension-bindings.js +9 -0
- package/dist/core/agent-session-extension-bindings.js.map +1 -1
- package/dist/core/agent-session-message-queue.d.ts +4 -1
- package/dist/core/agent-session-message-queue.d.ts.map +1 -1
- package/dist/core/agent-session-message-queue.js +35 -0
- package/dist/core/agent-session-message-queue.js.map +1 -1
- package/dist/core/agent-session-methods.d.ts +2 -1
- package/dist/core/agent-session-methods.d.ts.map +1 -1
- package/dist/core/agent-session-methods.js.map +1 -1
- package/dist/core/extensions/api-types.d.ts +3 -1
- package/dist/core/extensions/api-types.d.ts.map +1 -1
- package/dist/core/extensions/api-types.js.map +1 -1
- package/dist/core/extensions/index.d.ts +1 -1
- package/dist/core/extensions/index.d.ts.map +1 -1
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/loader-api.d.ts.map +1 -1
- package/dist/core/extensions/loader-api.js +4 -0
- package/dist/core/extensions/loader-api.js.map +1 -1
- package/dist/core/extensions/loader-runtime.d.ts.map +1 -1
- package/dist/core/extensions/loader-runtime.js +1 -0
- package/dist/core/extensions/loader-runtime.js.map +1 -1
- package/dist/core/extensions/message-types.d.ts +3 -0
- package/dist/core/extensions/message-types.d.ts.map +1 -1
- package/dist/core/extensions/message-types.js.map +1 -1
- package/dist/core/extensions/runner.d.ts.map +1 -1
- package/dist/core/extensions/runner.js +1 -0
- package/dist/core/extensions/runner.js.map +1 -1
- package/dist/core/extensions/runtime-types.d.ts +3 -1
- package/dist/core/extensions/runtime-types.d.ts.map +1 -1
- package/dist/core/extensions/runtime-types.js.map +1 -1
- package/docs/extensions.md +13 -0
- package/docs/subagents.md +2 -0
- package/docs/workflows.md +2 -2
- package/npm-shrinkwrap.json +23 -23
- 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.
|
|
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.
|
|
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 |
|
|
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
|
|
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
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 === "
|
|
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
|
|
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
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
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.
|
|
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.
|
|
191
|
-
|
|
192
|
-
|
|
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: () =>
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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": [
|