@bastani/atomic 0.9.6 → 0.9.7

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 (48) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/builtin/cursor/CHANGELOG.md +12 -0
  3. package/dist/builtin/cursor/package.json +2 -2
  4. package/dist/builtin/intercom/CHANGELOG.md +20 -0
  5. package/dist/builtin/intercom/README.md +1 -1
  6. package/dist/builtin/intercom/contact-supervisor-tool.ts +19 -32
  7. package/dist/builtin/intercom/index-heavy.ts +8 -51
  8. package/dist/builtin/intercom/index.ts +13 -1
  9. package/dist/builtin/intercom/intercom-tool.ts +24 -23
  10. package/dist/builtin/intercom/package.json +1 -1
  11. package/dist/builtin/intercom/reply-waiter.ts +115 -0
  12. package/dist/builtin/intercom/skills/intercom/SKILL.md +9 -2
  13. package/dist/builtin/intercom/subagent-relay.ts +11 -1
  14. package/dist/builtin/mcp/CHANGELOG.md +12 -0
  15. package/dist/builtin/mcp/package.json +1 -1
  16. package/dist/builtin/subagents/CHANGELOG.md +12 -0
  17. package/dist/builtin/subagents/package.json +1 -1
  18. package/dist/builtin/subagents/skills/subagent/SKILL.md +3 -1
  19. package/dist/builtin/subagents/src/runs/shared/model-fallback.ts +3 -2
  20. package/dist/builtin/web-access/CHANGELOG.md +12 -0
  21. package/dist/builtin/web-access/package.json +1 -1
  22. package/dist/builtin/workflows/CHANGELOG.md +40 -0
  23. package/dist/builtin/workflows/README.md +5 -5
  24. package/dist/builtin/workflows/builtin/goal-artifacts.ts +17 -4
  25. package/dist/builtin/workflows/builtin/goal-prompts.ts +32 -22
  26. package/dist/builtin/workflows/builtin/goal-reducer.ts +29 -5
  27. package/dist/builtin/workflows/builtin/goal-review.ts +6 -11
  28. package/dist/builtin/workflows/builtin/goal-runner.ts +10 -11
  29. package/dist/builtin/workflows/builtin/open-claude-design-runner.ts +2 -2
  30. package/dist/builtin/workflows/builtin/ralph-core.ts +5 -54
  31. package/dist/builtin/workflows/builtin/ralph-forked-prompts.ts +103 -0
  32. package/dist/builtin/workflows/builtin/ralph-models.ts +10 -10
  33. package/dist/builtin/workflows/builtin/ralph-review-gate.ts +27 -24
  34. package/dist/builtin/workflows/builtin/ralph-reviewer-prompt.ts +16 -9
  35. package/dist/builtin/workflows/builtin/ralph-runner.ts +47 -21
  36. package/dist/builtin/workflows/builtin/review-convergence.ts +118 -0
  37. package/dist/builtin/workflows/builtin/shared-prompts.ts +40 -0
  38. package/dist/builtin/workflows/package.json +1 -1
  39. package/dist/builtin/workflows/src/extension/workflow-prompts.ts +1 -0
  40. package/dist/builtin/workflows/src/runs/shared/model-fallback-failures.ts +4 -5
  41. package/dist/core/agent-session-retry.d.ts.map +1 -1
  42. package/dist/core/agent-session-retry.js +2 -2
  43. package/dist/core/agent-session-retry.js.map +1 -1
  44. package/docs/settings.md +1 -1
  45. package/docs/subagents.md +5 -1
  46. package/docs/workflows.md +111 -5
  47. package/npm-shrinkwrap.json +23 -23
  48. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.7] - 2026-07-12
6
+
7
+ ### Added
8
+
9
+ - Added a shared convergence contract to the bundled `goal` and `ralph` workflows: implementation starts from an observable acceptance/contract matrix derived from the literal objective/acceptance criteria (with explicit state/transition/invariant modeling for stateful work), reviewers independently derive adversarial checks from the literal contract before relying on worker receipts or worker-authored tests, reproduced findings require durable regression evidence before they count as resolved, and each review round persists a deduplicated cross-reviewer `consolidated_findings` batch that the next worker turn repairs together instead of one finding per turn. Literal-contract scope controls are preserved throughout, so nothing beyond the user's requirements is forced.
10
+ - Added a "Choosing an Execution Shape" section to `docs/workflows.md`: an agent-facing decision ladder covering inline work, inline subagent delegation, direct one-off `task`/`tasks`/`chain` shapes, named/builtin workflows, custom TypeScript workflows, and composed/nested workflows; a six-dimension scoring rubric (structure, verifiability, iteration, risk, duration, isolation) with hard-signal overrides; a "Task queues and software factories" playbook for fire-and-forget requests like "address all open issues" (enumerate and dependency-classify first, fan out independent items as separate per-item workflow runs in bounded waves with per-item worktrees/PRs, compose dependent items into one parent graph that nests proven children, and mix both for clustered queues); and a "Prompting the choice" guide listing the user prompt levers (naming the shape, acceptance criteria, loop wording, evidence requests, scope boundaries, and queue policy) that steer the agent's execution-shape decision.
11
+ - Added a "Context-Mode-Aware Prompt Text" section to `docs/workflows.md` documenting that stage prompts must not describe their own context mode, fresh stages must not reference invisible context (prior conversation, sibling stages, graph topology), and forked continuation prompts should send only the delta with a pointer back to guidance already established in the forked history.
12
+
13
+ ### Changed
14
+
15
+ - Accelerated PR/main CI by running platform-independent validation once on Linux while retaining installed-package Node integration and release-archive smoke coverage on Linux and Windows, and by reusing caller-installed dependencies and package builds during binary assembly. Test suites now have one bounded, observable flake-recovery attempt with preserved logs, environment/resource diagnostics, CI annotations, and no retries for deterministic workflow/release/package/publish gates. Release publication no longer reruns the full PR suite: a protected-default-branch integrity gate proves the release commit is generated from a parent already integrated into `main`, contains exactly the expected version and shrinkwrap material, and pins that immutable SHA across release jobs before preserving all release-specific metadata, docs, native, binary, package, and npm provenance checks.
16
+ - Changed bundled `goal` completion to evidence closure rather than reviewer agreement alone: reviewer quorum can only complete the run when no objective-relevant blocking finding from any reviewer in the current round remains unresolved, unresolved findings are recorded in the inspectable reducer decision reason, and the bounded loop still stops at `max_turns` as `needs_human`. Severity labels alone no longer dismiss objective-relevant findings in Goal or Ralph: `required_by_objective` findings block at any priority (P3 included), while `consistent_with_objective` P3 nice-to-haves stay non-blocking.
17
+
18
+ ### Fixed
19
+
20
+ - Fixed retryable-failure classification across main-chat retry/fallback, workflow stage fallback, and subagent fallback to treat provider usage-limit exhaustion (for example `Codex error: The usage limit has been reached`, plus `usage_limit_reached`/`insufficient_quota`-style codes) as a retryable quota/rate-limit failure, so configured `fallbackModels` advance to the next candidate provider/model instead of dead-ending the turn, stage, or run. Provider messages that flatten the token into free text (for example `usage_limit_reached` or `usage-limit`, matched with space/underscore/hyphen/joined separators) classify the same as the structured codes across all three paths. Nested cause/diagnostic and session-shaped error payloads classify the same way; cancellations, safety refusals, task/tool failures, and unrelated errors remain non-retryable.
21
+
22
+ ## [0.9.7-alpha.1] - 2026-07-12
23
+
24
+ ### Added
25
+
26
+ - Added a shared convergence contract to the bundled `goal` and `ralph` workflows: implementation starts from an observable acceptance/contract matrix derived from the literal objective/acceptance criteria (with explicit state/transition/invariant modeling for stateful work), reviewers independently derive adversarial checks from the literal contract before relying on worker receipts or worker-authored tests, reproduced findings require durable regression evidence before they count as resolved, and each review round persists a deduplicated cross-reviewer `consolidated_findings` batch that the next worker turn repairs together instead of one finding per turn. Literal-contract scope controls are preserved throughout, so nothing beyond the user's requirements is forced.
27
+ - Added a "Choosing an Execution Shape" section to `docs/workflows.md`: an agent-facing decision ladder covering inline work, inline subagent delegation, direct one-off `task`/`tasks`/`chain` shapes, named/builtin workflows, custom TypeScript workflows, and composed/nested workflows; a six-dimension scoring rubric (structure, verifiability, iteration, risk, duration, isolation) with hard-signal overrides; a "Task queues and software factories" playbook for fire-and-forget requests like "address all open issues" (enumerate and dependency-classify first, fan out independent items as separate per-item workflow runs in bounded waves with per-item worktrees/PRs, compose dependent items into one parent graph that nests proven children, and mix both for clustered queues); and a "Prompting the choice" guide listing the user prompt levers (naming the shape, acceptance criteria, loop wording, evidence requests, scope boundaries, and queue policy) that steer the agent's execution-shape decision.
28
+ - Added a "Context-Mode-Aware Prompt Text" section to `docs/workflows.md` documenting that stage prompts must not describe their own context mode, fresh stages must not reference invisible context (prior conversation, sibling stages, graph topology), and forked continuation prompts should send only the delta with a pointer back to guidance already established in the forked history.
29
+
30
+ ### Changed
31
+
32
+ - Changed bundled `goal` completion to evidence closure rather than reviewer agreement alone: reviewer quorum can only complete the run when no objective-relevant blocking finding from any reviewer in the current round remains unresolved, unresolved findings are recorded in the inspectable reducer decision reason, and the bounded loop still stops at `max_turns` as `needs_human`. Severity labels alone no longer dismiss objective-relevant findings in Goal or Ralph: `required_by_objective` findings block at any priority (P3 included), while `consistent_with_objective` P3 nice-to-haves stay non-blocking.
33
+
34
+ ### Fixed
35
+
36
+ - Fixed retryable-failure classification across main-chat retry/fallback, workflow stage fallback, and subagent fallback to treat provider usage-limit exhaustion (for example `Codex error: The usage limit has been reached`, plus `usage_limit_reached`/`insufficient_quota`-style codes) as a retryable quota/rate-limit failure, so configured `fallbackModels` advance to the next candidate provider/model instead of dead-ending the turn, stage, or run. Provider messages that flatten the token into free text (for example `usage_limit_reached` or `usage-limit`, matched with space/underscore/hyphen/joined separators) classify the same as the structured codes across all three paths. Nested cause/diagnostic and session-shaped error payloads classify the same way; cancellations, safety refusals, task/tool failures, and unrelated errors remain non-retryable.
37
+
5
38
  ## [0.9.6] - 2026-07-12
6
39
 
7
40
  ### Changed
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.7] - 2026-07-12
6
+
7
+ ### Changed
8
+
9
+ - Published the stable Atomic 0.9.7 release for the Cursor provider package; no functional Cursor provider changes were made after 0.9.6.
10
+
11
+ ## [0.9.7-alpha.1] - 2026-07-12
12
+
13
+ ### Changed
14
+
15
+ - Published a synchronized Atomic 0.9.7-alpha.1 prerelease for the Cursor provider package; no functional Cursor provider changes were made after 0.9.6.
16
+
5
17
  ## [0.9.6] - 2026-07-12
6
18
 
7
19
  ### Changed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/cursor",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
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.6",
43
+ "@bastani/atomic-natives": "0.9.7",
44
44
  "@bufbuild/protobuf": "^2.12.1",
45
45
  "@earendil-works/pi-ai": "^0.80.6"
46
46
  }
@@ -4,6 +4,26 @@ All notable changes to the `pi-intercom` extension will be documented in this fi
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.7] - 2026-07-12
8
+
9
+ ### Fixed
10
+
11
+ - Made blocking reply waits race-safe under concurrent tool calls. Waiter admission is now an atomic synchronous check-and-reserve shared by `intercom` `ask` and `contact_supervisor`: when several blocking requests race (parallel tool calls in one turn, same-tool or cross-tool), exactly one wins the reservation and every other call returns a normal structured "Already waiting for a reply" tool error. Previously the loser received an already-rejected promise that could sit unhandled while the winner's question was still being sent, crashing the whole agent process with an unhandled `Error: Already waiting for a reply` rejection.
12
+ - Scoped blocking-ask cleanup to the owning call: cancellation, send failures, and delivery errors now settle only that call's own reply waiter instead of rejecting whichever waiter happened to be pending, so a losing or failing concurrent request can no longer tear down another call's in-flight ask. The reply-wait timeout also rejects only its own waiter, threaded replies still resolve the exact winning request, and session shutdown/replacement and broker disconnects continue to reject the currently pending waiter.
13
+ - Made the pending reply-wait promise unhandled-rejection-proof: rejections that fire between the owner's awaits (for example a delivery failure racing dispatch) are pre-handled, so blocking coordination can never terminate the process.
14
+ - Initialized the lazy Intercom runtime from the most recent turn/tool/model lifecycle context when a subagent result or control relay arrives in a session that never emitted `session_start` to extensions (for example non-interactive in-process child sessions). Such sessions now deliver self-addressed subagent result announcements locally instead of failing every relay.
15
+ - Stopped recording misleading `intercom_result_error`/`intercom_control_error` "Intercom shutting down" entries when a relay fires while the runtime is uninitialized and the target is not the local session. The relay now acknowledges the message as undelivered (callers fall back to inline results) without attempting a broker connection that can only fail.
16
+
17
+ ## [0.9.7-alpha.1] - 2026-07-12
18
+
19
+ ### Fixed
20
+
21
+ - Made blocking reply waits race-safe under concurrent tool calls. Waiter admission is now an atomic synchronous check-and-reserve shared by `intercom` `ask` and `contact_supervisor`: when several blocking requests race (parallel tool calls in one turn, same-tool or cross-tool), exactly one wins the reservation and every other call returns a normal structured "Already waiting for a reply" tool error. Previously the loser received an already-rejected promise that could sit unhandled while the winner's question was still being sent, crashing the whole agent process with an unhandled `Error: Already waiting for a reply` rejection.
22
+ - Scoped blocking-ask cleanup to the owning call: cancellation, send failures, and delivery errors now settle only that call's own reply waiter instead of rejecting whichever waiter happened to be pending, so a losing or failing concurrent request can no longer tear down another call's in-flight ask. The reply-wait timeout also rejects only its own waiter, threaded replies still resolve the exact winning request, and session shutdown/replacement and broker disconnects continue to reject the currently pending waiter.
23
+ - Made the pending reply-wait promise unhandled-rejection-proof: rejections that fire between the owner's awaits (for example a delivery failure racing dispatch) are pre-handled, so blocking coordination can never terminate the process.
24
+ - Initialized the lazy Intercom runtime from the most recent turn/tool/model lifecycle context when a subagent result or control relay arrives in a session that never emitted `session_start` to extensions (for example non-interactive in-process child sessions). Such sessions now deliver self-addressed subagent result announcements locally instead of failing every relay.
25
+ - Stopped recording misleading `intercom_result_error`/`intercom_control_error` "Intercom shutting down" entries when a relay fires while the runtime is uninitialized and the target is not the local session. The relay now acknowledges the message as undelivered (callers fall back to inline results) without attempting a broker connection that can only fail.
26
+
7
27
  ## [0.9.6] - 2026-07-12
8
28
 
9
29
  ### Changed
@@ -344,7 +344,7 @@ Only registered in sessions where `pi-subagents` supplied the required child bri
344
344
 
345
345
  **`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
346
 
347
- **`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. Use this when the agent needs the answer to continue working.
347
+ **`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
348
 
349
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.
350
350
 
@@ -3,7 +3,7 @@ import { randomUUID } from "crypto";
3
3
  import { Type } from "typebox";
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import type { IntercomClient } from "./broker/client.ts";
6
- import type { Message } from "./types.ts";
6
+ import type { ReplyWait, ReplyWaitAdmission } from "./reply-waiter.ts";
7
7
  import { renderContactSupervisorResult } from "./result-renderers.js";
8
8
  import {
9
9
  type ChildOrchestratorMetadata,
@@ -23,13 +23,18 @@ interface ContactSupervisorDeps {
23
23
  ensureConnected(reason: "tool"): Promise<IntercomClient>;
24
24
  syncPresenceIdentity(sessionId: string): void;
25
25
  resolveSessionTarget(activeClient: IntercomClient, nameOrId: string): Promise<string | null>;
26
- waitForReply(from: string, replyTo: string, signal?: AbortSignal): Promise<Message>;
26
+ /**
27
+ * Atomically reserve the single reply-waiter slot. Returns a structured
28
+ * refusal when another blocking ask already holds it, so concurrent calls
29
+ * never observe a rejected promise.
30
+ */
31
+ beginReplyWait(from: string, replyTo: string, signal?: AbortSignal): ReplyWaitAdmission;
32
+ /** Advisory fast-path check; beginReplyWait is the authoritative reservation. */
27
33
  hasReplyWaiter(): boolean;
28
- rejectReplyWaiter(error: Error): void;
29
34
  }
30
35
 
31
36
  export function registerContactSupervisorTool(pi: ExtensionAPI, deps: ContactSupervisorDeps): void {
32
- const { childOrchestratorMetadata, ensureConnected, syncPresenceIdentity, resolveSessionTarget, waitForReply, hasReplyWaiter, rejectReplyWaiter } = deps;
37
+ const { childOrchestratorMetadata, ensureConnected, syncPresenceIdentity, resolveSessionTarget, beginReplyWait, hasReplyWaiter } = deps;
33
38
  if (childOrchestratorMetadata) {
34
39
  pi.registerTool({
35
40
  name: "contact_supervisor",
@@ -180,24 +185,18 @@ export function registerContactSupervisorTool(pi: ExtensionAPI, deps: ContactSup
180
185
  };
181
186
  }
182
187
 
183
- let replyPromise: Promise<Message> | null = null;
188
+ let wait: ReplyWait | null = null;
184
189
  try {
185
190
  const questionId = randomUUID();
186
- replyPromise = waitForReply(sendTo, questionId, signal);
187
- replyPromise.catch(() => undefined);
188
- if (signal?.aborted) {
189
- rejectReplyWaiter(new Error("Cancelled"));
190
- try {
191
- await replyPromise;
192
- } catch {
193
- // The waiter was intentionally rejected above; the tool result reports cancellation.
194
- }
191
+ const admission = beginReplyWait(sendTo, questionId, signal);
192
+ if (!admission.ok) {
195
193
  return {
196
- content: [{ type: "text", text: "Cancelled" }],
194
+ content: [{ type: "text", text: admission.reason === "busy" ? "Already waiting for a reply" : "Cancelled" }],
197
195
  isError: true,
198
196
  details: { error: true },
199
197
  };
200
198
  }
199
+ wait = admission.wait;
201
200
  const requestText = reason === "interview_request"
202
201
  ? formatChildOrchestratorMessage("interview", metadata, formatSupervisorInterviewRequest(supervisorInterview!, typeof params.message === "string" ? params.message : undefined))
203
202
  : formatChildOrchestratorMessage("ask", metadata, params.message as string);
@@ -208,14 +207,7 @@ export function registerContactSupervisorTool(pi: ExtensionAPI, deps: ContactSup
208
207
  });
209
208
  if (!sendResult.delivered) {
210
209
  const errorText = sendResult.reason ?? "Session may not exist or has disconnected.";
211
- rejectReplyWaiter(new Error(`Message to "${metadata.orchestratorTarget}" was not delivered: ${errorText}`));
212
- if (replyPromise) {
213
- try {
214
- await replyPromise;
215
- } catch {
216
- // The waiter was already rejected above. Keep the delivery failure as the only error here.
217
- }
218
- }
210
+ wait.cancel(new Error(`Message to "${metadata.orchestratorTarget}" was not delivered: ${errorText}`));
219
211
  return {
220
212
  content: [{ type: "text", text: `Message to "${metadata.orchestratorTarget}" was not delivered: ${errorText}` }],
221
213
  isError: true,
@@ -233,7 +225,7 @@ export function registerContactSupervisorTool(pi: ExtensionAPI, deps: ContactSup
233
225
  timestamp: Date.now(),
234
226
  subagent: { runId: metadata.runId, agent: metadata.agent, index: metadata.index },
235
227
  });
236
- const replyMessage = await replyPromise;
228
+ const replyMessage = await wait.promise;
237
229
  const replyText = replyMessage.content.text;
238
230
  const replyAttachments = replyMessage.content.attachments?.length
239
231
  ? formatAttachments(replyMessage.content.attachments)
@@ -256,14 +248,9 @@ export function registerContactSupervisorTool(pi: ExtensionAPI, deps: ContactSup
256
248
  : {},
257
249
  };
258
250
  } catch (error) {
259
- rejectReplyWaiter(toError(error));
260
- if (replyPromise) {
261
- try {
262
- await replyPromise;
263
- } catch {
264
- // The waiter is cleanup-only on this path. The real failure is the one from the outer catch.
265
- }
266
- }
251
+ // Settle only this call's own waiter; a concurrent call's
252
+ // reservation must never be torn down from this failure path.
253
+ wait?.cancel(toError(error));
267
254
  return {
268
255
  content: [{ type: "text", text: `Failed: ${getErrorMessage(error)}` }],
269
256
  isError: true,
@@ -6,6 +6,7 @@ import { InlineMessageComponent } from "./ui/inline-message.js";
6
6
  import { loadConfig, type IntercomConfig } from "./config.js";
7
7
  import type { SessionInfo, Message } from "./types.js";
8
8
  import { ReplyTracker } from "./reply-tracker.js";
9
+ import { ReplyWaiterSlot } from "./reply-waiter.js";
9
10
  import { registerContactSupervisorTool } from "./contact-supervisor-tool.js";
10
11
  import { registerIntercomTool } from "./intercom-tool.js";
11
12
  import { registerIntercomOverlay } from "./overlay.js";
@@ -45,54 +46,12 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
45
46
  let agentRunning = false;
46
47
  const activeTools = new Map<string, string>();
47
48
  const replyTracker = new ReplyTracker();
49
+ const replyWaiters = new ReplyWaiterSlot();
48
50
  const foregroundDetachHandoff = new ForegroundDetachHandoff(pi);
49
51
  const pendingIdleMessages: InboundMessageEntry[] = [];
50
52
  let inboundFlushTimer: NodeJS.Timeout | null = null;
51
- let replyWaiter: {
52
- from: string;
53
- replyTo: string;
54
- resolve: (message: Message) => void;
55
- reject: (error: Error) => void;
56
- } | null = null;
57
- function waitForReply(from: string, replyTo: string, signal?: AbortSignal): Promise<Message> {
58
- if (replyWaiter) {
59
- return Promise.reject(new Error("Already waiting for a reply"));
60
- }
61
- if (signal?.aborted) {
62
- return Promise.reject(new Error("Cancelled"));
63
- }
64
- return new Promise((resolve, reject) => {
65
- const timeout = setTimeout(() => {
66
- rejectReplyWaiter(new Error(`No reply from "${from}" within 10 minutes`));
67
- }, 10 * 60 * 1000);
68
- const cleanup = () => {
69
- clearTimeout(timeout);
70
- signal?.removeEventListener("abort", onAbort);
71
- if (replyWaiter?.replyTo === replyTo) {
72
- replyWaiter = null;
73
- }
74
- };
75
- const onAbort = () => {
76
- cleanup();
77
- reject(new Error("Cancelled"));
78
- };
79
- signal?.addEventListener("abort", onAbort, { once: true });
80
- replyWaiter = {
81
- from,
82
- replyTo,
83
- resolve: (message) => {
84
- cleanup();
85
- resolve(message);
86
- },
87
- reject: (error) => {
88
- cleanup();
89
- reject(error);
90
- },
91
- };
92
- });
93
- }
94
53
  function rejectReplyWaiter(error: Error): void {
95
- replyWaiter?.reject(error);
54
+ replyWaiters.rejectCurrent(error);
96
55
  }
97
56
  function clearReconnectTimer(): void {
98
57
  if (reconnectTimer) clearTimeout(reconnectTimer);
@@ -239,7 +198,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
239
198
  if (!liveContext) {
240
199
  return;
241
200
  }
242
- if (routeIncomingReply(replyWaiter, from, message)) return;
201
+ if (routeIncomingReply(replyWaiters.current(), from, message)) return;
243
202
  const attachmentText = message.content.attachments?.length
244
203
  ? formatAttachments(message.content.attachments)
245
204
  : "";
@@ -450,19 +409,17 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
450
409
  ensureConnected,
451
410
  syncPresenceIdentity,
452
411
  resolveSessionTarget,
453
- waitForReply,
454
- hasReplyWaiter: () => Boolean(replyWaiter),
455
- rejectReplyWaiter,
412
+ beginReplyWait: (from, replyTo, signal) => replyWaiters.begin(from, replyTo, signal),
413
+ hasReplyWaiter: () => replyWaiters.has(),
456
414
  });
457
415
  registerIntercomTool(pi, {
458
416
  ensureConnected,
459
417
  syncPresenceIdentity,
460
418
  resolveSessionTarget,
461
- waitForReply,
419
+ beginReplyWait: (from, replyTo, signal) => replyWaiters.begin(from, replyTo, signal),
462
420
  confirmSend: config.confirmSend,
463
421
  replyTracker,
464
- hasReplyWaiter: () => Boolean(replyWaiter),
465
- rejectReplyWaiter,
422
+ hasReplyWaiter: () => replyWaiters.has(),
466
423
  });
467
424
  registerIntercomOverlay(pi, {
468
425
  runtimeGeneration: () => runtimeGeneration,
@@ -294,9 +294,21 @@ export default function intercom(pi: ExtensionAPI, options: LightweightIntercomO
294
294
  handle.assertCurrent();
295
295
  },
296
296
  });
297
+ function latestLifecycleContext(): ExtensionContext | undefined {
298
+ // Sessions that never emit `session_start` to extensions (for example
299
+ // non-interactive in-process child sessions) still emit turn/tool/model
300
+ // lifecycle events. Fall back to the most recent lifecycle context so a
301
+ // relay-triggered heavy load can replay a synthetic `session_start` and
302
+ // initialize the runtime instead of relaying against a disposed one.
303
+ return sessionSnapshot?.ctx
304
+ ?? activeLifecycle.turnStart?.ctx
305
+ ?? activeLifecycle.agentStart?.ctx
306
+ ?? [...activeLifecycle.activeTools.values()].at(-1)?.ctx
307
+ ?? activeLifecycle.modelSelect?.ctx;
308
+ }
297
309
  for (const eventName of [SUBAGENT_CONTROL_INTERCOM_EVENT, SUBAGENT_RESULT_INTERCOM_EVENT] as const) {
298
310
  pi.events.on(eventName, (payload) => {
299
- void loadHeavy().then(async (handle) => {
311
+ void loadHeavy(latestLifecycleContext()).then(async (handle) => {
300
312
  handle.assertCurrent();
301
313
  await dispatchEventHandlers(handle.heavy, eventName, payload);
302
314
  handle.assertCurrent();
@@ -3,7 +3,7 @@ import { randomUUID } from "crypto";
3
3
  import { Type } from "typebox";
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import type { IntercomClient } from "./broker/client.ts";
6
- import type { Message } from "./types.ts";
6
+ import type { ReplyWait, ReplyWaitAdmission } from "./reply-waiter.ts";
7
7
  import { renderIntercomResult } from "./result-renderers.js";
8
8
  import {
9
9
  formatAttachments,
@@ -19,14 +19,19 @@ interface IntercomToolDeps {
19
19
  syncPresenceIdentity(sessionId: string): void;
20
20
  resolveSessionTarget(activeClient: IntercomClient, nameOrId: string): Promise<string | null>;
21
21
  confirmSend: boolean;
22
- waitForReply(from: string, replyTo: string, signal?: AbortSignal): Promise<Message>;
22
+ /**
23
+ * Atomically reserve the single reply-waiter slot. Returns a structured
24
+ * refusal when another blocking ask already holds it, so concurrent calls
25
+ * never observe a rejected promise.
26
+ */
27
+ beginReplyWait(from: string, replyTo: string, signal?: AbortSignal): ReplyWaitAdmission;
23
28
  replyTracker: ReplyTracker;
29
+ /** Advisory fast-path check; beginReplyWait is the authoritative reservation. */
24
30
  hasReplyWaiter(): boolean;
25
- rejectReplyWaiter(error: Error): void;
26
31
  }
27
32
 
28
33
  export function registerIntercomTool(pi: ExtensionAPI, deps: IntercomToolDeps): void {
29
- const { ensureConnected, syncPresenceIdentity, resolveSessionTarget, waitForReply, replyTracker, hasReplyWaiter, rejectReplyWaiter } = deps;
34
+ const { ensureConnected, syncPresenceIdentity, resolveSessionTarget, beginReplyWait, replyTracker, hasReplyWaiter } = deps;
30
35
  pi.registerTool({
31
36
  name: "intercom",
32
37
  label: "Intercom",
@@ -206,7 +211,7 @@ Usage:
206
211
  details: { error: true },
207
212
  };
208
213
  }
209
- let replyPromise: Promise<Message> | null = null;
214
+ let wait: ReplyWait | null = null;
210
215
 
211
216
  try {
212
217
  const sendTo = await resolveSessionTarget(connectedClient, to) ?? to;
@@ -225,7 +230,15 @@ Usage:
225
230
  };
226
231
  }
227
232
  const questionId = randomUUID();
228
- replyPromise = waitForReply(sendTo, questionId, _signal);
233
+ const admission = beginReplyWait(sendTo, questionId, _signal);
234
+ if (!admission.ok) {
235
+ return {
236
+ content: [{ type: "text", text: admission.reason === "busy" ? "Already waiting for a reply" : "Cancelled" }],
237
+ isError: true,
238
+ details: { error: true },
239
+ };
240
+ }
241
+ wait = admission.wait;
229
242
  const sendResult = await connectedClient.send(sendTo, {
230
243
  messageId: questionId,
231
244
  text: message,
@@ -236,14 +249,7 @@ Usage:
236
249
 
237
250
  if (!sendResult.delivered) {
238
251
  const errorText = sendResult.reason ?? "Session may not exist or has disconnected.";
239
- rejectReplyWaiter(new Error(`Message to "${to}" was not delivered: ${errorText}`));
240
- if (replyPromise) {
241
- try {
242
- await replyPromise;
243
- } catch {
244
- // The waiter was already rejected above. Keep the delivery failure as the only error here.
245
- }
246
- }
252
+ wait.cancel(new Error(`Message to "${to}" was not delivered: ${errorText}`));
247
253
  return {
248
254
  content: [{ type: "text", text: `Message to "${to}" was not delivered: ${errorText}` }],
249
255
  isError: true,
@@ -256,7 +262,7 @@ Usage:
256
262
  messageId: sendResult.id,
257
263
  timestamp: Date.now(),
258
264
  });
259
- const replyMessage = await replyPromise;
265
+ const replyMessage = await wait.promise;
260
266
  const replyText = replyMessage.content.text;
261
267
  const replyAttachments = replyMessage.content.attachments?.length
262
268
  ? formatAttachments(replyMessage.content.attachments)
@@ -273,14 +279,9 @@ Usage:
273
279
  details: {},
274
280
  };
275
281
  } catch (error) {
276
- rejectReplyWaiter(toError(error));
277
- if (replyPromise) {
278
- try {
279
- await replyPromise;
280
- } catch {
281
- // The waiter is cleanup-only on this path. The real failure is the one from the outer catch.
282
- }
283
- }
282
+ // Settle only this call's own waiter; a concurrent call's
283
+ // reservation must never be torn down from this failure path.
284
+ wait?.cancel(toError(error));
284
285
  return {
285
286
  content: [{ type: "text", text: `Failed: ${getErrorMessage(error)}` }],
286
287
  isError: true,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/intercom",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
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": [
@@ -0,0 +1,115 @@
1
+ import type { Message } from "./types.js";
2
+
3
+ export interface ReplyWaiterRecord {
4
+ from: string;
5
+ replyTo: string;
6
+ resolve(message: Message): void;
7
+ reject(error: Error): void;
8
+ }
9
+
10
+ /** Handle owned by the tool call that won waiter admission. */
11
+ export interface ReplyWait {
12
+ /**
13
+ * Resolves with the correlated reply, or rejects on timeout, cancellation,
14
+ * send failure, or disconnect. The rejection is pre-handled internally, so
15
+ * the promise can safely sit unawaited between other awaits (for example
16
+ * while the question is still being sent) without ever becoming an
17
+ * unhandled rejection.
18
+ */
19
+ promise: Promise<Message>;
20
+ /** Rejects only this waiter. No-op once it settled or was replaced. */
21
+ cancel(error: Error): void;
22
+ }
23
+
24
+ export type ReplyWaitAdmission =
25
+ | { ok: true; wait: ReplyWait }
26
+ | { ok: false; reason: "busy" | "cancelled" };
27
+
28
+ export const DEFAULT_REPLY_TIMEOUT_MS = 10 * 60 * 1000;
29
+
30
+ /**
31
+ * Single-slot reply waiter with atomic admission.
32
+ *
33
+ * Admission is a synchronous check-and-reserve: when two blocking asks race
34
+ * (parallel tool calls, cross-tool intercom/contact_supervisor concurrency),
35
+ * the first reservation wins and every concurrent loser receives a structured
36
+ * `{ ok: false, reason: "busy" }` refusal instead of a rejected promise.
37
+ * Cancellation and failure paths settle only their own waiter, so a losing or
38
+ * failing call can never tear down a reservation owned by another call.
39
+ */
40
+ export class ReplyWaiterSlot {
41
+ private waiter: ReplyWaiterRecord | null = null;
42
+
43
+ constructor(private readonly timeoutMs: number = DEFAULT_REPLY_TIMEOUT_MS) {}
44
+
45
+ /** The currently pending waiter, used for inbound reply correlation. */
46
+ current(): ReplyWaiterRecord | null {
47
+ return this.waiter;
48
+ }
49
+
50
+ has(): boolean {
51
+ return this.waiter !== null;
52
+ }
53
+
54
+ /**
55
+ * Rejects whichever waiter is currently pending. Reserved for slot-wide
56
+ * teardown (session shutdown/replacement, broker disconnect); individual
57
+ * tool calls must use their own `ReplyWait.cancel` instead.
58
+ */
59
+ rejectCurrent(error: Error): void {
60
+ this.waiter?.reject(error);
61
+ }
62
+
63
+ begin(from: string, replyTo: string, signal?: AbortSignal): ReplyWaitAdmission {
64
+ if (this.waiter) {
65
+ return { ok: false, reason: "busy" };
66
+ }
67
+ if (signal?.aborted) {
68
+ return { ok: false, reason: "cancelled" };
69
+ }
70
+ let record!: ReplyWaiterRecord;
71
+ const promise = new Promise<Message>((resolve, reject) => {
72
+ let settled = false;
73
+ const timeout = setTimeout(() => {
74
+ record.reject(new Error(`No reply from "${from}" within ${Math.round(this.timeoutMs / 60_000)} minutes`));
75
+ }, this.timeoutMs);
76
+ const onAbort = () => {
77
+ record.reject(new Error("Cancelled"));
78
+ };
79
+ const cleanup = () => {
80
+ settled = true;
81
+ clearTimeout(timeout);
82
+ signal?.removeEventListener("abort", onAbort);
83
+ if (this.waiter === record) {
84
+ this.waiter = null;
85
+ }
86
+ };
87
+ signal?.addEventListener("abort", onAbort, { once: true });
88
+ record = {
89
+ from,
90
+ replyTo,
91
+ resolve: (message) => {
92
+ if (settled) return;
93
+ cleanup();
94
+ resolve(message);
95
+ },
96
+ reject: (error) => {
97
+ if (settled) return;
98
+ cleanup();
99
+ reject(error);
100
+ },
101
+ };
102
+ this.waiter = record;
103
+ });
104
+ // Pre-attach a handler so a rejection that fires while the owner is
105
+ // between awaits can never crash the process as an unhandled rejection.
106
+ promise.catch(() => undefined);
107
+ return {
108
+ ok: true,
109
+ wait: {
110
+ promise,
111
+ cancel: (error) => record.reject(error),
112
+ },
113
+ };
114
+ }
115
+ }
@@ -305,6 +305,11 @@ If neither `cmux` nor `tmux` is available, skip this path and use normal `interc
305
305
  - **10-minute timeout**: If no reply comes within 10 minutes, the ask fails
306
306
  - **One at a time**: Cannot have multiple pending asks from the same session
307
307
  - **Cannot self-target**: A session cannot ask itself
308
+ - **Concurrency-safe**: If several blocking requests (multiple `ask` calls, or
309
+ `ask` plus `contact_supervisor`) race in the same session — for example from
310
+ parallel tool calls — exactly one wins the reservation. Every other call gets
311
+ a normal `"Already waiting for a reply"` tool error and can retry or fall
312
+ back to `send`; the losing calls never disturb the winning ask.
308
313
 
309
314
  ```typescript
310
315
  // Check if already waiting before asking
@@ -381,11 +386,13 @@ Use `/name` so others can target you easily:
381
386
 
382
387
  **"Already waiting for a reply"**
383
388
  ```typescript
384
- // You can only have one pending ask at a time
389
+ // You can only have one pending ask at a time. Concurrent blocking requests
390
+ // (parallel asks, or ask + contact_supervisor) return this error safely; the
391
+ // winning request keeps waiting for its reply.
385
392
  // Option 1: Use send instead
386
393
  intercom({ action: "send", to: "planner", message: "..." });
387
394
 
388
- // Option 2: Wait for current ask to complete first
395
+ // Option 2: Wait for the current ask to complete, then retry
389
396
  ```
390
397
 
391
398
  **"Cannot message the current session"**
@@ -105,7 +105,8 @@ export function registerSubagentRelay(pi: ExtensionAPI, deps: SubagentRelayDeps)
105
105
 
106
106
  const relayGeneration = deps.runtimeGeneration();
107
107
  void (async () => {
108
- const relayStillLive = () => !deps.runtimeStarted() || Boolean(getLiveContext(deps.runtimeContext(), relayGeneration));
108
+ const runtimeLive = () => Boolean(getLiveContext(deps.runtimeContext(), relayGeneration));
109
+ const relayStillLive = () => !deps.runtimeStarted() || runtimeLive();
109
110
  if (!relayStillLive()) {
110
111
  acknowledgeResult(options, parsed.requestId, false);
111
112
  return;
@@ -114,6 +115,15 @@ export function registerSubagentRelay(pi: ExtensionAPI, deps: SubagentRelayDeps)
114
115
  deliverLocal(parsed, options);
115
116
  return;
116
117
  }
118
+ if (!deps.runtimeStarted()) {
119
+ // The runtime never initialized for this session (no session_start
120
+ // reached the extension and no lifecycle context was available), so a
121
+ // broker connection attempt can only fail. Acknowledge the message as
122
+ // undelivered without recording a misleading connection error entry;
123
+ // callers treat an undelivered ack as their inline-result fallback.
124
+ acknowledgeResult(options, parsed.requestId, false, new Error("Intercom runtime not initialized"));
125
+ return;
126
+ }
117
127
 
118
128
  let activeClient: IntercomClient;
119
129
  let target: string;
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.7] - 2026-07-12
11
+
12
+ ### Changed
13
+
14
+ - Published the stable Atomic 0.9.7 release for the MCP extension; no functional MCP changes were made after 0.9.6.
15
+
16
+ ## [0.9.7-alpha.1] - 2026-07-12
17
+
18
+ ### Changed
19
+
20
+ - Published a synchronized Atomic 0.9.7-alpha.1 prerelease for the MCP extension; no functional MCP changes were made after 0.9.6.
21
+
10
22
  ## [0.9.6] - 2026-07-12
11
23
 
12
24
  ### Changed