@bastani/atomic 0.9.18-alpha.2 → 0.9.18-alpha.4

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 (32) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/builtin/intercom/CHANGELOG.md +8 -0
  3. package/dist/builtin/intercom/README.md +3 -3
  4. package/dist/builtin/intercom/broker/broker.ts +156 -12
  5. package/dist/builtin/intercom/broker/client.ts +89 -49
  6. package/dist/builtin/intercom/broker/pending-question-index.ts +10 -0
  7. package/dist/builtin/intercom/broker/send-handler.ts +32 -17
  8. package/dist/builtin/intercom/index.bundle.mjs +153 -50
  9. package/dist/builtin/intercom/package.json +1 -1
  10. package/dist/builtin/intercom/skills/intercom/SKILL.md +1 -1
  11. package/dist/builtin/intercom/types.ts +30 -2
  12. package/dist/builtin/mcp/package.json +1 -1
  13. package/dist/builtin/subagents/package.json +1 -1
  14. package/dist/builtin/web-access/package.json +1 -1
  15. package/dist/builtin/workflows/CHANGELOG.md +18 -0
  16. package/dist/builtin/workflows/README.md +11 -2
  17. package/dist/builtin/workflows/builtin/{chunk-ngffz3y8.js → chunk-fghhy2a5.js} +1 -1
  18. package/dist/builtin/workflows/builtin/{chunk-6v0yv8tj.js → chunk-h3r2vkzc.js} +1 -1
  19. package/dist/builtin/workflows/builtin/{chunk-brerg33r.js → chunk-n58a7v26.js} +0 -1
  20. package/dist/builtin/workflows/builtin/goal.js +2 -2
  21. package/dist/builtin/workflows/builtin/index.js +3 -3
  22. package/dist/builtin/workflows/builtin/ralph.js +2 -2
  23. package/dist/builtin/workflows/package.json +1 -1
  24. package/dist/builtin/workflows/src/extension/index.bundle.mjs +258 -49
  25. package/dist/builtin/workflows/src/index.js +35 -9
  26. package/docs/intercom.md +7 -5
  27. package/docs/models/artificial-analysis-index.md +2 -1
  28. package/docs/models/model-selection.md +19 -18
  29. package/docs/models/pareto-efficiency.md +36 -29
  30. package/docs/workflows.md +18 -9
  31. package/npm-shrinkwrap.json +32 -32
  32. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.18-alpha.4] - 2026-09-01
6
+
7
+ ### Changed
8
+
9
+ - The bundled model-selection reference documentation now tracks the August 26, 2026 DeepSWE v1.1 snapshot across the model-selection, Pareto-efficiency, and benchmark-source pages. Displayed pricing is corrected for GPT-5.6 Sol ($8.39 to $6.46 after OpenAI's promotional cut), DeepSeek V4 Pro ($1.67), and DeepSeek V4 Flash ($0.46). The recomputed Pareto frontier adds GLM-5.3 Flash as its cheapest point, moves both DeepSeek V4 configurations to provider-diversity options, and retains seven configurations no longer displayed on the live leaderboard as clearly labeled history. These static reference changes do not alter runtime routing or model defaults ([#2798](https://github.com/bastani-inc/atomic/pull/2798)).
10
+
5
11
  ## [0.9.18-alpha.2] - 2026-08-31
6
12
 
7
13
  ### Fixed
@@ -4,6 +4,14 @@ All notable changes to the `pi-intercom` extension will be documented in this fi
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.18-alpha.3] - 2026-09-01
8
+
9
+ ### Fixed
10
+
11
+ - Recoverable Intercom disconnects during lazy initialization no longer print a misleading error and stack trace in the interactive UI; later calls still retry initialization.
12
+ - Fixed workflow invocation control across isolated workflow-owned subgroups: canonical pending/running roster discovery, queued pending sends, immediate live send/ask with correlated replies, immutable registration-based directory authorization after mutable joins, directional sibling and cross-run isolation, and invocation-unique group names ([#2784](https://github.com/bastani-inc/atomic/issues/2784)).
13
+ - Durably queued pending-stage sends are now rendered as successful sends instead of failed sends ([#2784](https://github.com/bastani-inc/atomic/issues/2784)).
14
+
7
15
  ## [0.9.17] - 2026-08-29
8
16
 
9
17
  Cumulative release of the `0.9.17-alpha.1` prerelease. The summary below covers the user-visible outcome of that work; the per-change detail remains in the prerelease section below.
@@ -367,11 +367,11 @@ Only registered in sessions where `pi-subagents` supplied the required child bri
367
367
 
368
368
  **`list`** — Keeps session-listing semantics: returns the current session and every active session sharing at least one membership. Pass `group` for a read-only view of one group.
369
369
 
370
- Live target lookup accepts only an exact full session ID or an exact case-insensitive session name. Ordinary `send` also accepts `<runId>:<stageKey>` for a known workflow stage whose session has not initialized. The run ID is a full UUID and the stage key is exact. Unknown runs and stages retain the ordinary unknown-target failure. All targeting remains **group-scoped** see [Groups](#groups) below.
370
+ Live target lookup accepts only an exact full Intercom session ID or an exact case-insensitive session name. `intercom list` also returns materialized workflow-stage rows labeled `PENDING` or `RUNNING`, with canonical `<runId>:<stageId>` targets and actual groups. From the main chat, join `workflow:<rootRunId>` before controlling those targets. An eligible invocation member has directional list/send/live-ask control over stages in owned `workflow:<rootRunId>/...` subgroups. A subgroup stage or another workflow root cannot turn a mutable join into parent control; sibling subgroups stay isolated, and explicit `group: "default"` remains non-owned.
371
371
 
372
- **`send`** — Sends through ordinary Intercom. Live workflow-stage sessions receive messages immediately and return `delivered`. For a known pending stage, Atomic persists the message and returns the distinct `queued` result with its FIFO position, then delivers it through the ordinary inbound path when the stage session initializes. Each exact run/stage key retains at most 50 queued messages; the next send is refused rather than evicting an older one. Only sessions in the run's Intercom group may queue messages. Delivery occurs before the first model turn under **Messages received before you started**, with sender identity and `Sent:` timestamp separate from the task prompt. Resume/replay, broker restart, and stage-attempt restart preserve exactly-once delivery by logical message ID. Skipped, cancelled, and terminal-before-initialization destinations make pending messages undeliverable and notify the sender. If the sender reconnects with a new broker UUID, notification fallback requires one unique same-group match for the immutable registration-time name; mutable presence names/groups, cross-group matches, and ambiguous duplicates are rejected. The broker trusts that initial name/group as host-orchestration metadata, while the original display/provenance remains unchanged in the durable record. Live sends remain immediate by default; `confirmSend: true` still enables confirmation for non-reply sends.
372
+ **`send`** — Sends through ordinary Intercom. Live workflow-stage sessions receive messages immediately and return `delivered`. Known pending invocation-owned stages queue durably and return the distinct `queued` result with its FIFO position; a main-chat session that explicitly joined the owning invocation group may use that route. Pending `group: "default"` stages remain ineligible, and an ineligible attempt is refused with `Target workflow run is in a different intercom group`. Each exact run/stage key retains at most 50 queued messages; the next send is refused rather than evicting an older one. Delivery occurs before the first model turn under **Messages received before you started**, with sender identity and `Sent:` timestamp separate from the task prompt. Resume/replay, broker restart, and stage-attempt restart preserve exactly-once delivery by logical message ID. Skipped, cancelled, and terminal-before-initialization destinations make pending messages undeliverable and notify the sender. If the sender reconnects with a new broker UUID, notification fallback requires one unique same-group match for the immutable registration-time name; mutable presence names/groups, cross-group matches, and ambiguous duplicates are rejected. The broker trusts that initial name/group as host-orchestration metadata, while the original display/provenance remains unchanged in the durable record. Live sends remain immediate by default; `confirmSend: true` still enables confirmation for non-reply sends.
373
373
 
374
- **`ask`** — Sends a message and waits for a live recipient to reply (10-minute timeout). An ask to a known workflow stage whose session has not initialized is refused with `pending_stage_ask_unsupported` and recommends ordinary `send`, because holding a reply waiter until a stage eventually starts would be unbounded. A recipient disconnect after live delivery fails only that peer's exact wait promptly; the timeout remains the backstop while the recipient stays connected. Up to `maxPendingAsks` blocking asks (default: 6) may run concurrently, including same-target and mixed-target fan-out. Replies resolve by exact sender and message ID, so out-of-order replies cannot cross-settle another call. When capacity is full, new asks receive a structured refusal.
374
+ **`ask`** — Sends a message and waits for a live recipient to reply (10-minute timeout). Invocation control supports a live ask into an owned isolated subgroup, and the exact broker-recorded reply resolves the waiting tool call at the asker without opening reverse or lateral group access. Ask to an uninitialized stage remains refused with `pending_stage_ask_unsupported`; use queued `send` instead, because holding a reply waiter until a stage eventually starts would be unbounded. A recipient disconnect after live delivery fails only that peer's exact wait promptly; the timeout remains the backstop while the recipient stays connected. Up to `maxPendingAsks` blocking asks (default: 6) may run concurrently, including same-target and mixed-target fan-out. Replies resolve by exact sender and message ID, so out-of-order replies cannot cross-settle another call. When capacity is full, new asks receive a structured refusal.
375
375
 
376
376
  **`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/full session ID in `to`, or the listed message ID in `replyTo`; use `pending` to inspect them first. `replyTo` also disambiguates multiple asks from the same sender. Under the hood this is still a normal `send` with the exact `replyTo` value.
377
377
 
@@ -6,7 +6,14 @@ import { writeFileSync, unlinkSync, mkdirSync, readFileSync } from "fs";
6
6
  import { randomUUID } from "crypto";
7
7
  import { writeMessage, createMessageReader } from "./framing.js";
8
8
  import { getBrokerPidPath, getBrokerSocketPath, getIntercomDirPath } from "./paths.js";
9
- import type { SessionInfo, Message, BrokerMessage, SupervisorRegistration } from "../types.js";
9
+ import type {
10
+ SessionInfo,
11
+ Message,
12
+ BrokerMessage,
13
+ SupervisorRegistration,
14
+ WorkflowStageRosterAnnouncement,
15
+ WorkflowStageRosterEntry,
16
+ } from "../types.js";
10
17
  import { DeliveredMessageCache } from "./delivered-message-cache.js";
11
18
  import { isMessage } from "./client-message-validation.js";
12
19
  import { buildMessageSendSignature } from "./send-signature.js";
@@ -40,6 +47,13 @@ interface PendingStageRouteRegistration {
40
47
  readonly capability: string;
41
48
  }
42
49
 
50
+ interface WorkflowRosterRegistration {
51
+ readonly ownerSessionId: string;
52
+ readonly group: string;
53
+ readonly stages: WorkflowStageRosterAnnouncement[];
54
+ }
55
+
56
+
43
57
  interface PendingStageAcknowledgment {
44
58
  readonly ownerSessionId: string;
45
59
  readonly senderSocket: net.Socket;
@@ -116,6 +130,31 @@ function isSupervisorRegistration(value: unknown): value is SupervisorRegistrati
116
130
  && typeof registration.supervisorSessionId === "string";
117
131
  }
118
132
 
133
+ function invocationOwnsGroup(invocationGroup: string, candidateGroup: string): boolean {
134
+ const owner = normalizeGroup(invocationGroup);
135
+ const candidate = normalizeGroup(candidateGroup);
136
+ return candidate === owner || candidate.startsWith(`${owner}/`);
137
+ }
138
+
139
+ function isWorkflowStageRosterAnnouncements(value: unknown, runId: string): value is WorkflowStageRosterAnnouncement[] {
140
+ return (
141
+ Array.isArray(value) &&
142
+ value.every(
143
+ (stage) =>
144
+ typeof stage === "object" &&
145
+ stage !== null &&
146
+ typeof (stage as WorkflowStageRosterAnnouncement).stageId === "string" &&
147
+ typeof (stage as WorkflowStageRosterAnnouncement).stageName === "string" &&
148
+ (stage as WorkflowStageRosterAnnouncement).target ===
149
+ `${runId}:${(stage as WorkflowStageRosterAnnouncement).stageId}` &&
150
+ ((stage as WorkflowStageRosterAnnouncement).lifecycle === "pending" ||
151
+ (stage as WorkflowStageRosterAnnouncement).lifecycle === "running") &&
152
+ typeof (stage as WorkflowStageRosterAnnouncement).routeEligible === "boolean" &&
153
+ typeof (stage as WorkflowStageRosterAnnouncement).group === "string"
154
+ )
155
+ );
156
+ }
157
+
119
158
 
120
159
  class IntercomBroker {
121
160
  private sessions = new Map<string, ConnectedSession>();
@@ -128,6 +167,7 @@ class IntercomBroker {
128
167
  private pendingStageAcknowledgments = new Map<string, PendingStageAcknowledgment>();
129
168
  private pendingStageNotificationAcknowledgments = new Map<string, PendingStageNotificationAcknowledgment>();
130
169
  private liveWorkflowStageRoutes = new Map<string, LiveWorkflowStageRouteRegistration>();
170
+ private workflowRosters = new Map<string, WorkflowRosterRegistration>();
131
171
  private liveWorkflowStageRouteActivations = new Map<string, LiveWorkflowStageRouteActivation>();
132
172
 
133
173
  constructor() {
@@ -203,6 +243,74 @@ class IntercomBroker {
203
243
  return undefined;
204
244
  };
205
245
 
246
+ private canInspectSelectedGroup(requester: ConnectedSession, selectedGroup: string): boolean {
247
+ const selected = normalizeGroup(selectedGroup);
248
+ const groups = sessionGroups(requester.info);
249
+ for (const roster of this.workflowRosters.values()) {
250
+ if (!roster.stages.some((stage) => stage.group === selected && invocationOwnsGroup(roster.group, selected))) continue;
251
+ return groups.has(selected) || this.canControlWorkflowInvocation(requester, roster.group);
252
+ }
253
+ return true;
254
+ }
255
+
256
+ /**
257
+ * Invocation control may come from the invocation owner or from an ordinary
258
+ * host session that explicitly joined the invocation group. A workflow stage
259
+ * registered under any workflow group cannot turn mutable membership into a
260
+ * parent-control capability for a sibling (or for another workflow run).
261
+ */
262
+ private canControlWorkflowInvocation(sender: ConnectedSession, invocationGroup: string): boolean {
263
+ if (!hasGroup(sessionGroups(sender.info), invocationGroup)) return false;
264
+ const registrationGroup = normalizeGroup(sender.registrationGroup);
265
+ return registrationGroup === invocationGroup || !registrationGroup.startsWith("workflow:");
266
+ }
267
+
268
+ private canControlLiveWorkflowStage = (
269
+ sender: ConnectedSession,
270
+ target: ConnectedSession,
271
+ logicalTarget: string,
272
+ ): boolean => {
273
+ const parsed = parsePendingStageTarget(logicalTarget);
274
+ const owner = parsed === undefined ? undefined : this.pendingStageRoutes.get(parsed.runId);
275
+ const live = this.liveWorkflowStageRoutes.get(logicalTarget);
276
+ if (owner === undefined || live?.sessionId !== target.info.id) return false;
277
+ const targetGroup = target.registrationGroup ?? target.info.group ?? "default";
278
+ return this.canControlWorkflowInvocation(sender, owner.group) && invocationOwnsGroup(owner.group, targetGroup);
279
+ };
280
+
281
+ private workflowStagesVisibleTo(requester: ConnectedSession, selectedGroup?: string): WorkflowStageRosterEntry[] {
282
+ const requesterGroups = sessionGroups(requester.info);
283
+ const selected = selectedGroup === undefined ? undefined : normalizeGroup(selectedGroup);
284
+ const entries: WorkflowStageRosterEntry[] = [];
285
+ for (const [runId, roster] of this.workflowRosters) {
286
+ for (const stage of roster.stages) {
287
+ if (!stage.routeEligible) continue;
288
+ const parentControl =
289
+ this.canControlWorkflowInvocation(requester, roster.group) && invocationOwnsGroup(roster.group, stage.group);
290
+ const directMembership = requesterGroups.has(stage.group);
291
+ if (!parentControl && !directMembership) continue;
292
+ if (selected !== undefined && selected !== stage.group) continue;
293
+ const live = this.liveWorkflowStageRoutes.get(stage.target);
294
+ const liveSession = live === undefined ? undefined : this.sessions.get(live.sessionId);
295
+ if (stage.lifecycle === "running" && liveSession === undefined) continue;
296
+ // #2784: never list the requester's own stage as an "other" participant.
297
+ // Ordinary session rows already exclude self; the roster must match.
298
+ if (liveSession !== undefined && liveSession.info.id === requester.info.id) continue;
299
+ entries.push({
300
+ kind: "workflow-stage",
301
+ runId,
302
+ stageId: stage.stageId,
303
+ stageName: stage.stageName,
304
+ target: stage.target,
305
+ lifecycle: liveSession === undefined ? "pending" : "running",
306
+ group: stage.group,
307
+ ...(liveSession === undefined ? {} : { sessionId: liveSession.info.id }),
308
+ });
309
+ }
310
+ }
311
+ return entries;
312
+ }
313
+
206
314
  private acknowledgeLiveWorkflowStageRoute(requestId: string): void {
207
315
  const activation = this.liveWorkflowStageRouteActivations.get(requestId);
208
316
  if (activation === undefined || activation.pendingRequestIds.size > 0) return;
@@ -258,7 +366,7 @@ class IntercomBroker {
258
366
  this.pendingStageRoutes.delete(route.runId);
259
367
  return false;
260
368
  }
261
- if (route.liveTargetId === undefined && !hasGroup(sessionGroups(route.from.info), ownerRegistration.group)) {
369
+ if (route.liveTargetId === undefined && !this.canControlWorkflowInvocation(route.from, ownerRegistration.group)) {
262
370
  writeMessage(route.socket, {
263
371
  type: "delivery_failed",
264
372
  messageId: route.message.id,
@@ -659,10 +767,18 @@ class IntercomBroker {
659
767
 
660
768
  const requester = currentId ? this.sessions.get(currentId) : undefined;
661
769
  if (requester === undefined) throw new Error("Session not found");
662
- const sessions = typeof clientMessage.group === "string"
663
- ? sessionsInGroup(this.sessions, clientMessage.group)
664
- : sessionsVisibleTo(this.sessions, requester.info);
665
- writeMessage(socket, { type: "sessions", requestId: clientMessage.requestId, sessions });
770
+ const sessions =
771
+ typeof clientMessage.group === "string"
772
+ ? this.canInspectSelectedGroup(requester, clientMessage.group)
773
+ ? sessionsInGroup(this.sessions, clientMessage.group)
774
+ : []
775
+ : sessionsVisibleTo(this.sessions, requester.info);
776
+ writeMessage(socket, {
777
+ type: "sessions",
778
+ requestId: clientMessage.requestId,
779
+ sessions,
780
+ workflowStages: this.workflowStagesVisibleTo(requester, clientMessage.group),
781
+ });
666
782
  break;
667
783
  }
668
784
 
@@ -726,10 +842,26 @@ class IntercomBroker {
726
842
  socket.end();
727
843
  return;
728
844
  }
845
+ if (
846
+ clientMessage.stages !== undefined &&
847
+ (!isWorkflowStageRosterAnnouncements(clientMessage.stages, clientMessage.runId) ||
848
+ !clientMessage.stages.every((stage) => invocationOwnsGroup(ownerGroup, stage.group)))
849
+ ) {
850
+ writeMessage(socket, { type: "registration_failed", reason: "Invalid workflow-stage roster" });
851
+ socket.end();
852
+ return;
853
+ }
729
854
  if (activeExisting !== undefined && activeExisting.sessionId !== currentId) {
730
- // A stage replays the process-shared owner announcement before
731
- // registering its live aliases. Authenticate it without replacing
732
- // the workflow owner that handles pending delivery.
855
+ // A stage replays the process-shared owner announcement before registering its live aliases.
856
+ // It may publish the materialized roster, but the original workflow owner must continue to
857
+ // handle pending delivery and own roster cleanup.
858
+ if (clientMessage.stages !== undefined) {
859
+ this.workflowRosters.set(clientMessage.runId, {
860
+ ownerSessionId: activeExisting.sessionId,
861
+ group: ownerGroup,
862
+ stages: clientMessage.stages,
863
+ });
864
+ }
733
865
  break;
734
866
  }
735
867
  this.pendingStageRoutes.set(clientMessage.runId, {
@@ -737,6 +869,13 @@ class IntercomBroker {
737
869
  group: ownerGroup,
738
870
  capability: clientMessage.capability,
739
871
  });
872
+ if (clientMessage.stages !== undefined) {
873
+ this.workflowRosters.set(clientMessage.runId, {
874
+ ownerSessionId: currentId,
875
+ group: ownerGroup,
876
+ stages: clientMessage.stages,
877
+ });
878
+ }
740
879
  break;
741
880
  }
742
881
 
@@ -764,8 +903,10 @@ class IntercomBroker {
764
903
  ownerRegistration === undefined ||
765
904
  registeringSession === undefined ||
766
905
  ownerRegistration.capability !== clientMessage.capability ||
767
- normalizeGroup(ownerRegistration.group) !==
768
- normalizeGroup(registeringSession.registrationGroup ?? registeringSession.info.group) ||
906
+ !invocationOwnsGroup(
907
+ ownerRegistration.group,
908
+ registeringSession.registrationGroup ?? registeringSession.info.group ?? "default",
909
+ ) ||
769
910
  !this.registerLiveWorkflowStageRoute(
770
911
  currentId,
771
912
  clientMessage.requestId,
@@ -821,6 +962,7 @@ class IntercomBroker {
821
962
  this.pendingQuestions,
822
963
  this.routePendingStage,
823
964
  this.resolveLiveWorkflowStage,
965
+ this.canControlLiveWorkflowStage,
824
966
  );
825
967
  break;
826
968
  }
@@ -873,7 +1015,9 @@ class IntercomBroker {
873
1015
  }
874
1016
 
875
1017
  for (const [runId, owner] of this.pendingStageRoutes) {
876
- if (owner.sessionId === sessionId) this.pendingStageRoutes.delete(runId);
1018
+ if (owner.sessionId !== sessionId) continue;
1019
+ this.pendingStageRoutes.delete(runId);
1020
+ this.workflowRosters.delete(runId);
877
1021
  }
878
1022
  for (const [requestId, pending] of this.pendingStageAcknowledgments) {
879
1023
  if (pending.ownerSessionId !== sessionId) continue;
@@ -3,7 +3,16 @@ import net from "net";
3
3
  import { randomUUID } from "crypto";
4
4
  import { writeMessage, createMessageReader } from "./framing.js";
5
5
  import { getBrokerSocketPath } from "./paths.js";
6
- import type { SessionInfo, Message, Attachment, GroupSummary, SupervisorRegistration } from "../types.js";
6
+ import type {
7
+ SessionInfo,
8
+ Message,
9
+ Attachment,
10
+ GroupSummary,
11
+ SupervisorRegistration,
12
+ SessionDirectory,
13
+ WorkflowStageRosterAnnouncement,
14
+ WorkflowStageRosterEntry,
15
+ } from "../types.js";
7
16
  import { buildSendSignature, PendingSendRegistry } from "./pending-send-registry.js";
8
17
  import { readSubagentMessageSource } from "../source-ownership.js";
9
18
  import { isMessage, isSessionInfo } from "./client-message-validation.js";
@@ -74,6 +83,27 @@ function toError(error: unknown): Error {
74
83
  }
75
84
 
76
85
 
86
+ function isWorkflowStageRosterEntries(value: unknown): value is WorkflowStageRosterEntry[] {
87
+ return (
88
+ Array.isArray(value) &&
89
+ value.every(
90
+ (entry) =>
91
+ typeof entry === "object" &&
92
+ entry !== null &&
93
+ (entry as WorkflowStageRosterEntry).kind === "workflow-stage" &&
94
+ typeof (entry as WorkflowStageRosterEntry).runId === "string" &&
95
+ typeof (entry as WorkflowStageRosterEntry).stageId === "string" &&
96
+ typeof (entry as WorkflowStageRosterEntry).stageName === "string" &&
97
+ typeof (entry as WorkflowStageRosterEntry).target === "string" &&
98
+ ((entry as WorkflowStageRosterEntry).lifecycle === "pending" ||
99
+ (entry as WorkflowStageRosterEntry).lifecycle === "running") &&
100
+ typeof (entry as WorkflowStageRosterEntry).group === "string" &&
101
+ ((entry as WorkflowStageRosterEntry).sessionId === undefined ||
102
+ typeof (entry as WorkflowStageRosterEntry).sessionId === "string"),
103
+ )
104
+ );
105
+ }
106
+
77
107
  export class IntercomClient extends EventEmitter {
78
108
  private readonly returnAddress: string;
79
109
  private socket: net.Socket | null = null;
@@ -83,7 +113,7 @@ export class IntercomClient extends EventEmitter {
83
113
  private _messageSource: Message["source"] | undefined;
84
114
  private pendingSends = new PendingSendRegistry();
85
115
  private pendingGroupLists = new Map<string, { resolve: (groups: GroupSummary[]) => void; reject: (error: Error) => void }>();
86
- private pendingLists = new Map<string, { resolve: (sessions: SessionInfo[]) => void; reject: (e: Error) => void }>();
116
+ private pendingLists = new Map<string, { resolve: (directory: SessionDirectory) => void; reject: (e: Error) => void }>();
87
117
  private pendingPresence = new Map<string, {
88
118
  resolve: (group: string) => void;
89
119
  reject: (error: Error) => void;
@@ -329,19 +359,21 @@ export class IntercomClient extends EventEmitter {
329
359
  break;
330
360
  }
331
361
  case "sessions": {
332
- const { requestId, sessions } = brokerMessage;
333
- if (typeof requestId !== "string" || !Array.isArray(sessions) || !sessions.every(isSessionInfo)) {
334
- throw new Error("Invalid sessions message");
335
- }
336
- const pending = this.pendingLists.get(requestId);
337
- if (!pending) {
338
- // Late list responses can still arrive after the caller has already timed out.
339
- return;
340
- }
341
- this.pendingLists.delete(requestId);
342
- pending.resolve(sessions);
343
- break;
344
- }
362
+ const { requestId, sessions, workflowStages } = brokerMessage;
363
+ if (
364
+ typeof requestId !== "string" ||
365
+ !Array.isArray(sessions) ||
366
+ !sessions.every(isSessionInfo) ||
367
+ (workflowStages !== undefined && !isWorkflowStageRosterEntries(workflowStages))
368
+ ) {
369
+ throw new Error("Invalid sessions message");
370
+ }
371
+ const pending = this.pendingLists.get(requestId);
372
+ if (!pending) return;
373
+ this.pendingLists.delete(requestId);
374
+ pending.resolve({ sessions, workflowStages: workflowStages ?? [] });
375
+ break;
376
+ }
345
377
  case "groups": {
346
378
  const { requestId, groups } = brokerMessage;
347
379
  if (
@@ -602,38 +634,41 @@ export class IntercomClient extends EventEmitter {
602
634
  }
603
635
  });
604
636
  }
605
- listSessions(group?: string): Promise<SessionInfo[]> {
606
- let socket: net.Socket;
607
- try {
608
- socket = this.requireActiveSocket();
609
- } catch (error) {
610
- return Promise.reject(toError(error));
611
- }
612
- return new Promise((resolve, reject) => {
613
- const requestId = randomUUID();
614
- const wrappedResolve = (sessions: SessionInfo[]) => {
615
- clearTimeout(timeout);
616
- resolve(sessions);
617
- };
618
- const wrappedReject = (error: Error) => {
619
- clearTimeout(timeout);
620
- reject(error);
621
- };
622
- const timeout = setTimeout(() => {
623
- if (this.pendingLists.has(requestId)) {
624
- this.pendingLists.delete(requestId);
625
- wrappedReject(new Error("List sessions timeout"));
626
- }
627
- }, 5000);
628
- this.pendingLists.set(requestId, { resolve: wrappedResolve, reject: wrappedReject });
629
- try {
630
- writeMessage(socket, group === undefined ? { type: "list", requestId } : { type: "list", requestId, group });
631
- } catch (error) {
632
- clearTimeout(timeout);
633
- this.pendingLists.delete(requestId);
634
- reject(toError(error));
635
- }
636
- });
637
+ async listSessions(group?: string): Promise<SessionInfo[]> {
638
+ return (await this.listDirectory(group)).sessions;
639
+ }
640
+
641
+ listDirectory(group?: string): Promise<SessionDirectory> {
642
+ let socket: net.Socket;
643
+ try {
644
+ socket = this.requireActiveSocket();
645
+ } catch (error) {
646
+ return Promise.reject(toError(error));
647
+ }
648
+ return new Promise((resolve, reject) => {
649
+ const requestId = randomUUID();
650
+ const timeout = setTimeout(() => {
651
+ if (!this.pendingLists.delete(requestId)) return;
652
+ reject(new Error("List sessions timeout"));
653
+ }, 5000);
654
+ this.pendingLists.set(requestId, {
655
+ resolve: (directory) => {
656
+ clearTimeout(timeout);
657
+ resolve(directory);
658
+ },
659
+ reject: (error) => {
660
+ clearTimeout(timeout);
661
+ reject(error);
662
+ },
663
+ });
664
+ try {
665
+ writeMessage(socket, group === undefined ? { type: "list", requestId } : { type: "list", requestId, group });
666
+ } catch (error) {
667
+ clearTimeout(timeout);
668
+ this.pendingLists.delete(requestId);
669
+ reject(toError(error));
670
+ }
671
+ });
637
672
  }
638
673
  listGroups(): Promise<GroupSummary[]> {
639
674
  let socket: net.Socket;
@@ -728,8 +763,13 @@ export class IntercomClient extends EventEmitter {
728
763
  }
729
764
  });
730
765
  }
731
- registerPendingStageRoute(runId: string, group: string, capability: string): void {
732
- writeMessage(this.requireActiveSocket(), { type: "register_pending_stage_route", runId, group, capability });
766
+ registerPendingStageRoute(
767
+ runId: string,
768
+ group: string,
769
+ capability: string,
770
+ stages?: WorkflowStageRosterAnnouncement[],
771
+ ): void {
772
+ writeMessage(this.requireActiveSocket(), { type: "register_pending_stage_route", runId, group, capability, stages });
733
773
  }
734
774
 
735
775
  registerLiveWorkflowStageRoute(runId: string, stageKeys: readonly string[], capability: string): Promise<void> {
@@ -15,6 +15,16 @@ export class PendingQuestionIndex {
15
15
  this.questions.set(messageId, routes);
16
16
  }
17
17
 
18
+ /** Authorize only the exact reverse route of a broker-recorded live ask. */
19
+ matchesReply(senderSessionId: string, targetSessionId: string, replyTo: string): boolean {
20
+ return (
21
+ this.questions
22
+ .get(replyTo)
23
+ ?.some(
24
+ (route) => route.targetSessionId === senderSessionId && route.senderSessionId === targetSessionId,
25
+ ) === true
26
+ );
27
+ }
18
28
  clearReply(senderSessionId: string, targetSessionId: string, replyTo: string): boolean {
19
29
  const routes = this.questions.get(replyTo);
20
30
  const index = routes?.findIndex(
@@ -42,6 +42,11 @@ export interface PendingStageRoute {
42
42
  export type PendingStageRouter = (route: PendingStageRoute) => boolean;
43
43
 
44
44
  export type LiveWorkflowStageResolver = (target: string) => BrokerConnectedSession | undefined;
45
+ export type LiveWorkflowStageController = (
46
+ sender: BrokerConnectedSession,
47
+ target: BrokerConnectedSession,
48
+ logicalTarget: string,
49
+ ) => boolean;
45
50
 
46
51
  const WORKFLOW_RUN_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
47
52
 
@@ -56,6 +61,8 @@ export const PENDING_STAGE_ASK_REFUSAL =
56
61
  "Cannot ask a workflow stage whose session has not initialized. Use send; Atomic will queue the message until the stage session initializes.";
57
62
 
58
63
 
64
+
65
+
59
66
  interface SendClientMessage extends Record<string, unknown> {
60
67
  type: string;
61
68
  }
@@ -68,16 +75,17 @@ function wireMessageId(value: unknown): string {
68
75
 
69
76
  /** Validate and route one wire-level send request. */
70
77
  export function handleBrokerSend(
71
- socket: net.Socket,
72
- clientMessage: SendClientMessage,
73
- currentId: string | null,
74
- sessions: Map<string, BrokerConnectedSession>,
75
- deliveredMessages: DeliveredMessageCache,
76
- write: (target: net.Socket, message: BrokerMessage) => void,
77
- supervisorCache: SupervisorChannelCache = new SupervisorChannelCache(),
78
- pendingQuestions: PendingQuestionIndex = new PendingQuestionIndex(),
79
- routePendingStage?: PendingStageRouter,
80
- resolveLiveWorkflowStage?: LiveWorkflowStageResolver,
78
+ socket: net.Socket,
79
+ clientMessage: SendClientMessage,
80
+ currentId: string | null,
81
+ sessions: Map<string, BrokerConnectedSession>,
82
+ deliveredMessages: DeliveredMessageCache,
83
+ write: (target: net.Socket, message: BrokerMessage) => void,
84
+ supervisorCache: SupervisorChannelCache = new SupervisorChannelCache(),
85
+ pendingQuestions: PendingQuestionIndex = new PendingQuestionIndex(),
86
+ routePendingStage?: PendingStageRouter,
87
+ resolveLiveWorkflowStage?: LiveWorkflowStageResolver,
88
+ canControlLiveWorkflowStage?: LiveWorkflowStageController,
81
89
  ): void {
82
90
  const message = clientMessage.message;
83
91
  const messageId = wireMessageId(message);
@@ -158,13 +166,20 @@ export function handleBrokerSend(
158
166
  write(socket, { type: "delivery_failed", messageId: message.id, attemptId, reason: "Cannot message the current session" });
159
167
  return;
160
168
  }
161
- const bypass = supervisorSend || isVerticalBypass({
162
- replyTo: message.replyTo,
163
- sender: fromSession.info,
164
- target: target.info,
165
- supervisorCache,
166
- });
167
- if (!bypass && !sameGroup(target.info, fromSession.info)) {
169
+ const correlatedReply =
170
+ message.replyTo !== undefined &&
171
+ pendingQuestions.matchesReply(fromSession.info.id, target.info.id, message.replyTo);
172
+ const bypass =
173
+ supervisorSend ||
174
+ isVerticalBypass({
175
+ replyTo: message.replyTo,
176
+ sender: fromSession.info,
177
+ target: target.info,
178
+ supervisorCache,
179
+ }) ||
180
+ correlatedReply ||
181
+ (liveWorkflowTarget !== undefined && canControlLiveWorkflowStage?.(fromSession, target, trimmedTo) === true);
182
+ if (!bypass && !sameGroup(target.info, fromSession.info)) {
168
183
  write(socket, {
169
184
  type: "delivery_failed",
170
185
  messageId: message.id,