@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.
- package/CHANGELOG.md +33 -0
- package/dist/builtin/cursor/CHANGELOG.md +12 -0
- package/dist/builtin/cursor/package.json +2 -2
- package/dist/builtin/intercom/CHANGELOG.md +20 -0
- package/dist/builtin/intercom/README.md +1 -1
- package/dist/builtin/intercom/contact-supervisor-tool.ts +19 -32
- package/dist/builtin/intercom/index-heavy.ts +8 -51
- package/dist/builtin/intercom/index.ts +13 -1
- package/dist/builtin/intercom/intercom-tool.ts +24 -23
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/intercom/reply-waiter.ts +115 -0
- package/dist/builtin/intercom/skills/intercom/SKILL.md +9 -2
- package/dist/builtin/intercom/subagent-relay.ts +11 -1
- package/dist/builtin/mcp/CHANGELOG.md +12 -0
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/CHANGELOG.md +12 -0
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/subagents/skills/subagent/SKILL.md +3 -1
- package/dist/builtin/subagents/src/runs/shared/model-fallback.ts +3 -2
- package/dist/builtin/web-access/CHANGELOG.md +12 -0
- package/dist/builtin/web-access/package.json +1 -1
- package/dist/builtin/workflows/CHANGELOG.md +40 -0
- package/dist/builtin/workflows/README.md +5 -5
- package/dist/builtin/workflows/builtin/goal-artifacts.ts +17 -4
- package/dist/builtin/workflows/builtin/goal-prompts.ts +32 -22
- package/dist/builtin/workflows/builtin/goal-reducer.ts +29 -5
- package/dist/builtin/workflows/builtin/goal-review.ts +6 -11
- package/dist/builtin/workflows/builtin/goal-runner.ts +10 -11
- package/dist/builtin/workflows/builtin/open-claude-design-runner.ts +2 -2
- package/dist/builtin/workflows/builtin/ralph-core.ts +5 -54
- package/dist/builtin/workflows/builtin/ralph-forked-prompts.ts +103 -0
- package/dist/builtin/workflows/builtin/ralph-models.ts +10 -10
- package/dist/builtin/workflows/builtin/ralph-review-gate.ts +27 -24
- package/dist/builtin/workflows/builtin/ralph-reviewer-prompt.ts +16 -9
- package/dist/builtin/workflows/builtin/ralph-runner.ts +47 -21
- package/dist/builtin/workflows/builtin/review-convergence.ts +118 -0
- package/dist/builtin/workflows/builtin/shared-prompts.ts +40 -0
- package/dist/builtin/workflows/package.json +1 -1
- package/dist/builtin/workflows/src/extension/workflow-prompts.ts +1 -0
- package/dist/builtin/workflows/src/runs/shared/model-fallback-failures.ts +4 -5
- package/dist/core/agent-session-retry.d.ts.map +1 -1
- package/dist/core/agent-session-retry.js +2 -2
- package/dist/core/agent-session-retry.js.map +1 -1
- package/docs/settings.md +1 -1
- package/docs/subagents.md +5 -1
- package/docs/workflows.md +111 -5
- package/npm-shrinkwrap.json +23 -23
- 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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
|
188
|
+
let wait: ReplyWait | null = null;
|
|
184
189
|
try {
|
|
185
190
|
const questionId = randomUUID();
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
454
|
-
hasReplyWaiter: () =>
|
|
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
|
-
|
|
419
|
+
beginReplyWait: (from, replyTo, signal) => replyWaiters.begin(from, replyTo, signal),
|
|
462
420
|
confirmSend: config.confirmSend,
|
|
463
421
|
replyTracker,
|
|
464
|
-
hasReplyWaiter: () =>
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|