Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
2c560dd
chore(pro): point beta at latest Pro main
alichherawalla Oct 5, 2026
b1428ab
chore(beta): 0.0.112-beta.1 (build 1791224063) [skip ci]
alichherawalla Oct 5, 2026
cd15fea
ui: use Ares helmet icon for Assistant controls
alichherawalla Oct 6, 2026
7128f92
fix(mcp): route same-named tools to the server the user picked
alichherawalla Oct 6, 2026
5962357
fix(image): cancel stops a remote image transfer and never saves it
alichherawalla Oct 6, 2026
cce8e47
fix(image): a failed remote image transfer leaves no file behind
alichherawalla Oct 6, 2026
55b6a9c
fix(chats): say when a deleted chat's images could not be removed
alichherawalla Oct 6, 2026
6b912de
fix(mcp): tools screen shows the server's real state when it has no t…
alichherawalla Oct 6, 2026
1e99a2a
fix(chat): deleting the open chat says when its images could not be r…
alichherawalla Oct 6, 2026
366ff18
fix(image): cancel before a remote transfer starts means it never starts
alichherawalla Oct 6, 2026
c9a7141
fix(mcp): a tool is never moved to another account without the user c…
alichherawalla Oct 6, 2026
ef7a026
fix(image): a result for a deleted chat is never saved
alichherawalla Oct 6, 2026
84d7f81
fix(chat): deleting a chat stops its image and waits for it to end
alichherawalla Oct 6, 2026
a38fe5e
test(chat): deleting a chat mid-image stops it and saves nothing
alichherawalla Oct 6, 2026
7b32a90
fix(engines): a standalone text request can be stopped by its caller
alichherawalla Oct 6, 2026
d666287
fix(image): cancelling during prompt enhancement stops its text request
alichherawalla Oct 6, 2026
2dbf3e7
test(chat): deleting a chat mid-enhancement stops it and saves nothing
alichherawalla Oct 6, 2026
b967156
ci(test): run Jest in one recycled worker so the late suites stop swa…
alichherawalla Oct 7, 2026
dd91d1b
chore(pro): combine audit fixes and Ares icon with core dependency
alichherawalla Oct 7, 2026
29b1707
test(mcp): a tool never switches on for another server by itself
alichherawalla Oct 7, 2026
2cde77a
test(mcp): a switched-off tool never runs
alichherawalla Oct 7, 2026
8823271
fix(image): a cancelled image request can never touch the next one
alichherawalla Oct 7, 2026
dcbc8c7
test(image): a cancelled image stays stopped and does not overlap the…
alichherawalla Oct 7, 2026
56ea44c
fix(voice): a transcript only reaches the chat it was spoken in
alichherawalla Oct 7, 2026
a5bef58
test(voice): a note transcribed after a chat switch is not sent
alichherawalla Oct 7, 2026
362f07a
test(tts): Stop during the voice model load plays nothing
alichherawalla Oct 7, 2026
a08ac68
fix(vision): preserve repair metadata when clearing projector links
alichherawalla Oct 7, 2026
365036d
fix(vision): keep scanned projector init failures repairable
alichherawalla Oct 7, 2026
cc896f6
Match Pro pricing copy to getoffgridai.co: $69 lifetime or $4.99/month
alichherawalla Oct 8, 2026
ed12ec2
State the next price tier: $119 lifetime or $7.99/month
alichherawalla Oct 8, 2026
6a66b52
Tidy the pricing comment
alichherawalla Oct 8, 2026
67bfba6
Merge remote-tracking branch 'origin/feat/uat-latest-main-pro-2026100…
alichherawalla Oct 8, 2026
7f945ef
Merge remote-tracking branch 'origin/fix/pricing-match-website' into …
alichherawalla Oct 8, 2026
7bd0699
test(sync): the sync journeys keep their op history in a real databas…
alichherawalla Oct 8, 2026
a0dc4ba
fix(models): a downloaded image model stays on the Image tab, marked …
alichherawalla Oct 8, 2026
8832f39
fix(gallery): hide records whose image file is not on this device
alichherawalla Oct 8, 2026
268d3c1
fix(tools): tool switches read clearly on or off
alichherawalla Oct 8, 2026
5174009
fix(chat): the thought-process preview ends in an ellipsis, not mid-word
alichherawalla Oct 8, 2026
99a4139
fix(models): GGUF models show their catalog name, not the repo id
alichherawalla Oct 8, 2026
fbc6c6d
fix(remote): Desktop model names never show a file path
alichherawalla Oct 8, 2026
609de83
fix(theme): project chat titles and the New Project header use the th…
alichherawalla Oct 8, 2026
382a25d
fix(a11y): alert and dropdown contents reach screen readers and autom…
alichherawalla Oct 8, 2026
bf89028
chore(pro): bump for the OGAM mobile fixes
alichherawalla Oct 8, 2026
222ae42
Revert "fix(gallery): hide records whose image file is not on this de…
alichherawalla Oct 8, 2026
733a41e
test(chat): the thinking preview is plain text with no Markdown syntax
alichherawalla Oct 8, 2026
ed6f10b
feat(mic): read the microphone permission without prompting, on both …
alichherawalla Oct 8, 2026
bbdb264
fix(mic): the mic button shows access is off when the permission is d…
alichherawalla Oct 8, 2026
3cab883
test(mic): a denied microphone, on iOS and Android, through the real …
alichherawalla Oct 8, 2026
5ec4908
refactor(remote): displayModelName lives in utils, breaking a service…
alichherawalla Oct 8, 2026
82fc2ea
feat(dev): a Settings switch hides the developer sync status bar
alichherawalla Oct 8, 2026
f1a4a83
feat(dev): load a demo project, chats and documents from Settings
alichherawalla Oct 8, 2026
d78c418
chore(pro): bump for the demo sync seed
alichherawalla Oct 8, 2026
681c802
chore(pro): bump for the demo transfer fix
alichherawalla Oct 8, 2026
0148f1f
feat(dev): demo seed adds the email draft handoff, in progress and done
alichherawalla Oct 8, 2026
3ca0fce
fix(release): provision dependencies and preserve failed image cleanup
alichherawalla Oct 8, 2026
0232fda
fix(image): reject empty remote image downloads
alichherawalla Oct 8, 2026
a2239e8
fix(ci): install iOS dependencies and report native test failures
alichherawalla Oct 8, 2026
2164a72
fix(remote): preserve cancellation and normalized gateway routes
alichherawalla Oct 8, 2026
5359692
fix(image): cancel the Desktop job when Mobile stops generation
alichherawalla Oct 8, 2026
4d7dac1
fix(deps): use maintained XML parser in Expo build tooling
alichherawalla Oct 8, 2026
0ce7996
Verify Desktop image cancellation through HTTP and honor Stop races
alichherawalla Oct 8, 2026
c2e1cec
Patch compatible Mobile tooling and MCP dependencies
alichherawalla Oct 8, 2026
e7645c1
docs: privacy policy support email is support@getoffgridai.co
alichherawalla Oct 8, 2026
1a67552
Keep Worklets runtime packaging with its native owner
alichherawalla Oct 9, 2026
081a061
Merge PR #704 support email correction into PR #701
alichherawalla Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,22 @@ jobs:
- name: Run Jest tests
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
env:
# The serial RN + jsdom suite retains more than Node's default 2 GB heap before
# coverage is written. The macOS runner has 14 GB; keep enough headroom for native
# tooling while allowing the complete suite to finish without weakening the gate.
NODE_OPTIONS: --max-old-space-size=8192
run: npx jest --coverage --forceExit --runInBand
# Serial, but in ONE recycled worker instead of --runInBand.
#
# In band, every suite runs in the same process and the heap left after each suite only
# grows: measured locally on 2dbf3e75, it climbs from ~0.4 GB after the first suite to
# ~6.3 GB after the last, with a 7.1 GB peak RSS. The macos-26-arm64 runner has 7 GB of
# RAM, so the back half of the run swaps. That is why CI throughput collapses (100
# suites took ~3 min early in run 37503803398 and ~19 min at the end) and why a
# different set of late, otherwise fast tests times out on every run, on main too.
#
# --maxWorkers=1 keeps the run serial (the parallel run flaked on CPU contention, see
# b18e65f5). --workerIdleMemoryLimit replaces that worker once its heap passes 1 GB
# after a suite, so memory stays bounded for the whole run. The worker heap cap only
# has to fit one worker's lifetime now, not the whole suite, so it sits well below
# the runner's RAM and V8 collects before the box starts swapping.
NODE_OPTIONS: --max-old-space-size=4096
run: npx jest --coverage --forceExit --maxWorkers=1 --workerIdleMemoryLimit=1GB

- name: Install Android NDK
# Configuring the native modules (op-sqlite etc.) needs the pinned NDK; the macOS
Expand All @@ -212,15 +223,20 @@ jobs:
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: cd android && ./gradlew :app:testDebugUnitTest --rerun-tasks

- name: Setup Ruby for iOS dependencies
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true

- name: Install iOS dependencies
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: bundle exec pod install --project-directory=ios

- name: Run iOS tests
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: |
cd ios && xcodebuild test \
-workspace OffgridMobile.xcworkspace \
-scheme OffgridMobile \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:OffgridMobileTests \
2>&1 | (xcpretty 2>/dev/null || cat)
run: npm run test:ios

- name: Upload coverage to Codecov
if: always()
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/release-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,45 @@ jobs:
VERSION=$(node -p "require('./package.json').version")
echo "VERSION=$VERSION" >> $GITHUB_ENV

- name: Read pinned Pro revision
id: pro_revision
run: echo "sha=$(git rev-parse HEAD:pro)" >> "$GITHUB_OUTPUT"

- name: Check out pinned Pro source
uses: actions/checkout@v4
with:
repository: off-grid-ai/mobile-pro
token: ${{ secrets.PRO_SUBMODULE_PAT }}
path: pro
ref: ${{ steps.pro_revision.outputs.sha }}
persist-credentials: false

- name: Check out shared packages
uses: actions/checkout@v4
with:
repository: off-grid-ai/shared
token: ${{ secrets.PRO_SUBMODULE_PAT }}
path: _shared
ref: main
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Build shared packages
run: |
mv _shared ../shared
npm --prefix ../shared ci
npm --prefix ../shared/packages/automation run build
npm --prefix ../shared/packages/sync run build
npm --prefix ../shared/packages/rag run build
npm --prefix ../shared/packages/models run build
npm --prefix ../shared/packages/speech run build
npm --prefix ../shared/packages/ui run build

- name: Install dependencies
run: npm ci

Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Fetch all history for changelog

- name: Read pinned Pro revision
id: pro_revision
run: echo "sha=$(git rev-parse HEAD:pro)" >> "$GITHUB_OUTPUT"

- name: Check out pinned Pro source
uses: actions/checkout@v4
with:
repository: off-grid-ai/mobile-pro
token: ${{ secrets.PRO_SUBMODULE_PAT }}
path: pro
ref: ${{ steps.pro_revision.outputs.sha }}
persist-credentials: false

- name: Check out shared packages
uses: actions/checkout@v4
with:
repository: off-grid-ai/shared
token: ${{ secrets.PRO_SUBMODULE_PAT }}
path: _shared
ref: main
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@v4
with:
Expand Down Expand Up @@ -57,6 +79,17 @@ jobs:
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-

- name: Build shared packages
run: |
mv _shared ../shared
npm --prefix ../shared ci
npm --prefix ../shared/packages/automation run build
npm --prefix ../shared/packages/sync run build
npm --prefix ../shared/packages/rag run build
npm --prefix ../shared/packages/models run build
npm --prefix ../shared/packages/speech run build
npm --prefix ../shared/packages/ui run build

- name: Install dependencies
run: npm ci

Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/uat-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,26 @@ jobs:
git config --global url."https://x-access-token:${PRO_PAT}@github.com/".insteadOf "https://github.com/"
git submodule update --init --recursive pro
git config --global --unset url."https://x-access-token:${PRO_PAT}@github.com/".insteadOf || true
- name: Check out shared packages
uses: actions/checkout@v4
with:
repository: off-grid-ai/shared
token: ${{ secrets.PRO_SUBMODULE_PAT }}
path: _shared
ref: main
persist-credentials: false
- uses: actions/setup-node@v4
with: { node-version: '20', cache: npm }
- name: Build shared packages
run: |
mv _shared ../shared
npm --prefix ../shared ci
npm --prefix ../shared/packages/automation run build
npm --prefix ../shared/packages/sync run build
npm --prefix ../shared/packages/rag run build
npm --prefix ../shared/packages/models run build
npm --prefix ../shared/packages/speech run build
npm --prefix ../shared/packages/ui run build
- run: npm ci
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '17' }
Expand Down
7 changes: 4 additions & 3 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ function App() {
const applyPendingProRedirect = useProExpiryRedirect();
const [isInitializing, setIsInitializing] = useState(true);
const startupGeneration = useRef(0);
const showSyncStatusBar = useAppStore(s => s.showSyncStatusBar);
const setDeviceInfo = useAppStore((s) => s.setDeviceInfo);
const setModelRecommendation = useAppStore((s) => s.setModelRecommendation);
const setDownloadedModels = useAppStore((s) => s.setDownloadedModels);
Expand Down Expand Up @@ -406,7 +407,7 @@ function App() {
<SystemBars style={isDark ? 'light' : 'dark'} />
<LoadingDots size={8} testID="startup-loading-dots" />
</View>
{__DEV__ ? <DevSyncStrip /> : null}
{__DEV__ && showSyncStatusBar ? <DevSyncStrip /> : null}
</SafeAreaProvider>
</GestureHandlerRootView>
);
Expand All @@ -419,7 +420,7 @@ function App() {
<SafeAreaProvider>
<SystemBars style={isDark ? 'light' : 'dark'} />
<LockScreen onUnlock={handleUnlock} />
{__DEV__ ? <DevSyncStrip /> : null}
{__DEV__ && showSyncStatusBar ? <DevSyncStrip /> : null}
</SafeAreaProvider>
</GestureHandlerRootView>
);
Expand Down Expand Up @@ -465,7 +466,7 @@ function App() {
>
<AppNavigator />
</NavigationContainer>
{__DEV__ ? <DevSyncStrip /> : null}
{__DEV__ && showSyncStatusBar ? <DevSyncStrip /> : null}
</SafeAreaProvider>
</GestureHandlerRootView>
);
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
[![Platform](https://img.shields.io/badge/Platform-Android%20%7C%20iOS%20%7C%20macOS-green.svg)](#install)
[![codecov](https://codecov.io/gh/off-grid-ai/OGAM/graph/badge.svg)](https://codecov.io/gh/off-grid-ai/OGAM)
[![Slack](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/off-grid-mobile/shared_invite/zt-411pbtz7r-lcOK4YCeY40vh_~FUdcvLA)
[![Pro](https://img.shields.io/badge/Off%20Grid%20Pro-%2469%20lifetime%20and%20%2449%20annual-000000?style=flat)](https://offgridmobileai.co/pay/)
[![Pro](https://img.shields.io/badge/Off%20Grid%20Pro-%24119%20lifetime%20or%20%247.99%20monthly-000000?style=flat)](https://offgridmobileai.co/pay/)

</div>

Expand All @@ -37,7 +37,7 @@

## Off Grid AI Pro

**A voice, personas, and actions. $69 for life, or $49/year.**
**A voice, personas, and actions. $119 for life, or $7.99/month.**

</div>

Expand All @@ -52,7 +52,7 @@ Pro is an optional, additive tier. It gives the assistant a voice that talks bac
- **Draft, then approve** - connect Calendar, email, and MCP servers like Linear, Notion, and GitHub. It drafts the reply or files the ticket and waits. Nothing sends without your tap.
- **Personal Mesh sync** - your phone and your Mac share chats, files, and settings over your own network. There is no relay. See the [Personal Mesh guide](docs/PERSONAL_MESH.md).

**[→ Get Pro access](https://offgridmobileai.co/pay/)** - $69 once and it is yours forever (the price climbs as more people join, never down), or $49/year.
**[→ Get Pro access](https://offgridmobileai.co/pay/)** - $119 once and it is yours forever (the price climbs as more people join, never down), or $7.99/month.

Pair it with **[Off Grid AI Desktop](https://github.com/off-grid-ai/desktop/releases/latest)** on your Mac. One Pro license covers both.

Expand Down
7 changes: 4 additions & 3 deletions __tests__/hardening/batch2-chatslist.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
formatWeekday,
} from '../../src/utils/localTime';
import React from 'react';
import { render, fireEvent, within } from '@testing-library/react-native';
import { render, fireEvent, within, act } from '@testing-library/react-native';
import { useAppStore } from '../../src/stores/appStore';
import { useChatStore } from '../../src/stores/chatStore';
import { resetStores } from '../utils/testHelpers';
Expand Down Expand Up @@ -189,7 +189,7 @@ describe('batch2 ChatsListScreen — sort, timestamp format, delete, empty', ()
// Case 31 (through the UI): the swipe-delete action opens a confirm alert whose
// Delete button removes exactly that conversation from the real store, leaving the
// rest intact.
it('case31: confirming the swipe-delete alert removes exactly that conversation', () => {
it('case31: confirming the swipe-delete alert removes exactly that conversation', async () => {
const a = createConversation({ title: 'Alpha', updatedAt: new Date('2024-06-02T10:00:00Z').toISOString() });
const b = createConversation({ title: 'Bravo', updatedAt: new Date('2024-06-01T10:00:00Z').toISOString() });
useChatStore.setState({ conversations: [a, b] });
Expand All @@ -207,7 +207,8 @@ describe('batch2 ChatsListScreen — sort, timestamp format, delete, empty', ()
const call = mockShowAlert.mock.calls.find(c => c[0] === 'Delete Chat')!;
const buttons = call[2] as any[];
const del = buttons.find(btn => btn.text === 'Delete')!;
del.onPress();
// The delete removes the chat's image files first, then the chat.
await act(async () => { await del.onPress(); });

const remaining = useChatStore.getState().conversations;
expect(remaining).toHaveLength(1);
Expand Down
28 changes: 24 additions & 4 deletions __tests__/harness/chatHarness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,20 +730,40 @@ export async function setupChatScreen(opts: ChatHarnessOptions) {
/** Mount the real ChatScreen (plus the real app.root slot when pro is active, so the TTS EngineBridge
* mounts and the voice engine can load over the executorch fake — the same slot App.tsx renders). */
render() {
this.view = rtl.render(this.screenTree());
return this.view;
},

/** The mounted tree: ChatScreen, plus the app.root slot when pro is active. Fresh elements each call. */
screenTree(): React.ReactElement {
const { ChatScreen } = require('../../src/screens/ChatScreen');
const { getSlot, SLOTS } = require('../../src/bootstrap/slotRegistry');

const AppRoot = opts.pro ? getSlot(SLOTS.appRoot) : undefined;
const tree = AppRoot
return AppRoot
? React.createElement(
React.Fragment,
null,
React.createElement(AppRoot, {}),
React.createElement(ChatScreen, {}),
)
: React.createElement(ChatScreen, {});
this.view = rtl.render(tree);
return this.view;
},

/**
* Open another chat on the SAME mounted ChatScreen, as navigating to a chat from the list does: the
* native stack keeps the screen and hands it new route params. No id opens a new chat. Anything
* still in flight in the composer (a transcription, a send) stays alive across the switch.
*/
async openConversation(conversationId?: string) {
// The screen reads the route through the module registry the harness reset into, which holds its
// own copy of this module; set the params there.
require('./chatHarness').routeHolder.params = conversationId ? { conversationId } : {};
await rtl.act(async () => {
this.view!.rerender(this.screenTree());
});
await rtl.waitFor(() => {
expect(useChatStore.getState().activeConversationId).toBe(conversationId ?? null);
});
},

/**
Expand Down
83 changes: 83 additions & 0 deletions __tests__/harness/mcpHttpFake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Behavior-faithful fake of remote MCP servers at the network boundary (XMLHttpRequest, which
* McpClient uses). Each registered URL is an in-memory Streamable-HTTP MCP server: it answers
* initialize, tools/list and tools/call with JSON-RPC bodies the way a real server does, and it
* runs a called tool by producing text that names the server that ran it. Everything above the
* transport (McpClient, mcpService, the store, the screens) runs for real.
*/
import type { McpTool } from '@offgrid/pro/mcp/types';

export interface FakeMcpServer {
label: string;
tools: McpTool[];
}

export interface McpHttpFake {
/** Every tools/call that reached a server, in order. */
calls: Array<{ url: string; tool: string }>;
/** A down server drops every request with a network error, as an unreachable host does. */
setDown: (url: string, down: boolean) => void;
/** Park the next request to `url` until the returned release() is called. */
holdNext: (url: string) => { release: () => void };
restore: () => void;
}

export function installMcpHttpFake(servers: Record<string, FakeMcpServer>): McpHttpFake {
const calls: McpHttpFake['calls'] = [];
const down = new Set<string>();
const holds = new Map<string, Promise<void>>();
const original = (global as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest;

class FakeMcpXHR {
status = 0;
responseText = '';
timeout = 0;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
ontimeout: null | (() => void) = null;
onabort: null | (() => void) = null;
private url = '';
private headers: Record<string, string> = {};

open(_method: string, url: string): void { this.url = url; }
setRequestHeader(): void { /* headers are not part of this server's behavior */ }
getResponseHeader(name: string): string | null { return this.headers[name.toLowerCase()] ?? null; }
abort(): void { this.onabort?.(); }

send(payload: string): void {
const server = servers[this.url];
const hold = holds.get(this.url);
holds.delete(this.url);
const answer = () => setTimeout(() => {
if (!server || down.has(this.url)) { this.onerror?.(); return; }
const req = JSON.parse(payload) as { id: number; method: string; params: { name?: string } };
this.status = 200;
this.headers = { 'content-type': 'application/json' };
let result: unknown;
if (req.method === 'initialize') {
result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: server.label } };
} else if (req.method === 'tools/list') {
result = { tools: server.tools };
} else if (req.method === 'tools/call') {
calls.push({ url: this.url, tool: req.params.name ?? '' });
result = { content: [{ type: 'text', text: `${server.label} ran ${req.params.name}` }] };
}
this.responseText = result === undefined ? '' : JSON.stringify({ jsonrpc: '2.0', id: req.id, result });
this.onload?.();
}, 0);
if (hold) hold.then(answer); else answer();
}
}

(global as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = FakeMcpXHR;
return {
calls,
setDown: (url, isDown) => { if (isDown) down.add(url); else down.delete(url); },
holdNext: url => {
let release = () => {};
holds.set(url, new Promise<void>(resolve => { release = resolve; }));
return { release: () => release() };
},
restore: () => { (global as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = original; },
};
}
Loading
Loading