Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
18c006f
docs(webui): settle ADR 0020 for hosting Mini Apps in the WebUI
Fectivnfy112357 Oct 26, 2026
2ee6a83
feat(webui): forward port the Mini App contract and accept the newer …
Fectivnfy112357 Oct 26, 2026
b234fc2
test(webui): move the Mini App reader suite out of src
Fectivnfy112357 Oct 26, 2026
79e92a5
fix(webui): name the new manifest rejections and pin their messages
Fectivnfy112357 Oct 26, 2026
e55e935
feat(webui): guard every Mini App loopback server with the host crede…
Fectivnfy112357 Oct 26, 2026
cf6c9fb
feat(webui): guard every Mini App loopback server with the host crede…
Fectivnfy112357 Oct 26, 2026
de0fc2e
refactor(webui): make Mini App loopback readiness its own seam
Fectivnfy112357 Oct 26, 2026
40cadee
test(webui): prove the Mini App credential is per generation
Fectivnfy112357 Oct 26, 2026
28b5eb8
feat(webui): probe a derived Mini App port before spawning
Fectivnfy112357 Oct 26, 2026
2a462c4
feat(webui): take Mini App ownership through an exclusively bound end…
Fectivnfy112357 Oct 26, 2026
fb56967
feat(webui): refuse a Mini App another client owns before spawning it
Fectivnfy112357 Oct 26, 2026
b3b0981
feat(webui): serve a Mini App page through its own loopback proxy
Fectivnfy112357 Oct 26, 2026
5ca1c6e
feat(webui): make the proxied Mini App document framable and carry th…
Fectivnfy112357 Oct 26, 2026
44af7f5
test(webui): keep a Mini App's view URL stable across a proxy restart
Fectivnfy112357 Oct 26, 2026
08ed45c
feat(webui): make the Mini App service a cell of the runtime owner po…
Fectivnfy112357 Oct 26, 2026
dbaa979
feat(webui): present a Mini App through the session rail
Fectivnfy112357 Oct 26, 2026
52ceb09
feat(webui): forward Mini App list, open and stop on the client entry
Fectivnfy112357 Oct 26, 2026
3ec25de
feat(webui): hand the Mini App's launch credential to the caller that…
Fectivnfy112357 Oct 26, 2026
75c504b
feat(webui): serve a Mini App through a sealed loopback view
Fectivnfy112357 Oct 26, 2026
1f6184b
feat(webui): host a Mini App in the workspace panel and the plugin ca…
Fectivnfy112357 Oct 26, 2026
3508bdb
feat(webui): manage installed Mini App enablement
Fectivnfy112357 Oct 26, 2026
d281f25
fix(webui): refresh Mini App status after open
Fectivnfy112357 Oct 26, 2026
d99ae63
feat(webui): move a Mini App into its own browser tab
Fectivnfy112357 Oct 26, 2026
ebfa5d5
fix(webui): stop a detached Mini App tab from going stale
Fectivnfy112357 Oct 26, 2026
20d0e34
fix(webui): do not reopen an app stopped from another document
Fectivnfy112357 Oct 26, 2026
dccb607
test(webui): cover the Mini App panel's remaining acceptance paths
Fectivnfy112357 Oct 26, 2026
f174563
feat(local-runtime-v2): run a Mini App agent turn in the runtime
Fectivnfy112357 Oct 26, 2026
4feda0b
chore(webui): keep Mini App agent run events out of the host event ca…
Fectivnfy112357 Oct 26, 2026
16bb2fd
test(webui): pin the Mini App agent frame exclusion in the event cata…
Fectivnfy112357 Oct 26, 2026
8ae190b
fix(local-runtime-v2): deny unanswerable Mini App asks and reserve ow…
Fectivnfy112357 Oct 26, 2026
6e53d29
fix(local-runtime-v2): pin the non-root ownership guard and type its …
Fectivnfy112357 Oct 26, 2026
07b48f6
feat(local-runtime-v2): carry the Mini App connector on child IPC
Fectivnfy112357 Oct 26, 2026
6b4e135
test(local-runtime-v2): run the runner exit cases against the real ch…
Fectivnfy112357 Oct 26, 2026
6fcf385
chore(webui): keep Mini App connector run frames out of the host even…
Fectivnfy112357 Oct 26, 2026
0c4d55c
feat(webui): bridge a Mini App page to an agent turn
Fectivnfy112357 Oct 27, 2026
1e43187
test(webui): pin the Mini App bridge's operation and generation delta
Fectivnfy112357 Oct 27, 2026
fc5bf36
fix(webui): close Mini App leases on the real selection path and fenc…
Fectivnfy112357 Oct 27, 2026
5301e12
fix(webui): re-report a shown Mini App session when the person returns
Fectivnfy112357 Oct 27, 2026
ced3db3
fix(webui): stop re-asking when the runtime refuses a re-shown session
Fectivnfy112357 Oct 27, 2026
ba9f2cc
fix(webui): settle a failed re-report and never claim a session alrea…
Fectivnfy112357 Oct 27, 2026
ff1ded8
test(webui): complete Mini App panel acceptance coverage
Fectivnfy112357 Oct 27, 2026
494931d
merge: bring Mini App hosting to current webui baseline
Fectivnfy112357 Oct 27, 2026
193ee9c
fix(webui): close Mini App review gaps
Fectivnfy112357 Oct 27, 2026
391fefa
fix(local-runtime-v2): enforce Mini App cross-client ownership
Fectivnfy112357 Oct 27, 2026
bd745d5
fix(webui): close Mini App hosting review gaps
Fectivnfy112357 Oct 27, 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
18 changes: 16 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,19 +535,33 @@ _Avoid_: mini program, app, widget

**Mini App view**:
The client area that renders a Mini App's page. It is either a view inside the workspace panel or
the whole content of a browser tab opened for one Mini App.
the whole content of a browser tab opened for one Mini App. Both are the client's choice: a Mini App
page cannot ask to be shown, moved or detached.
_Avoid_: surface (that word names a client identity here), pane, frame

**Mini App bridge**:
The page-facing API a Mini App page uses to call the client hosting it: appending a draft to the
composer, running or answering an agent turn, and reading the host's window state.
composer, and running or answering an agent turn. Presentation is not part of it.
_Avoid_: API, interface, plugin API

**Mini App process**:
The local process that serves one Mini App's page on a loopback port. The client hosts its output
and does not own its state.
_Avoid_: runtime (that names the harness side), server, container

**Mini App proxy**:
The loopback listener the service puts between a Mini App's process and the client's view. The
Mini App serves its own page under a credential its own guard demands; the client frames the proxy's
URL instead, and the proxy injects that credential on the way out. The process's origin and the
credential are server-only, so an operation answers with the proxy and never with the process.
_Avoid_: gateway, tunnel, port forward

**Mini App presentation lease**:
The record of which client document is currently showing one Mini App, held by the service because
two presentations of the same Mini App are two documents that share nothing but that service. It is
released when the document's connection closes.
_Avoid_: session, tab state, presence

### Runtime ownership

**Runtime owner**:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@ const LIFECYCLE_FAILURES: Readonly<
"workspace-required": { status: 400, key: "WORKSPACE_REQUIRED" },
"parent-not-found": { status: 404, key: "SESSION_PARENT_NOT_FOUND" },
"invalid-session-kind": { status: 400, key: "INVALID_SESSION_KIND" },
"purpose-reserved": { status: 400, key: "SESSION_PURPOSE_RESERVED" },
"parent-required": { status: 400, key: "SESSION_PARENT_REQUIRED" },
"session-not-found": { status: 404, key: "SESSION_NOT_FOUND" },
"session-busy": { status: 409, key: "SESSION_BUSY" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,8 @@ const PREPARATION_REASON_BY_HOST_CODE: Readonly<Record<string, MiniAppFailureRea
MINIAPP_RUNTIME_INVALID: 'MINIAPP_RUNTIME_INVALID',
MINIAPP_RUNTIME_ENTRY_NOT_ARTIFACT: 'MINIAPP_RUNTIME_ENTRY_INVALID',
MINIAPP_SURFACE_INVALID: 'MINIAPP_SURFACE_INVALID',
MINIAPP_AGENT_ACCESS_INVALID: 'MINIAPP_MANIFEST_INVALID',
MINIAPP_AGENT_CONCURRENCY_INVALID: 'MINIAPP_MANIFEST_INVALID',
MINIAPP_MCP_ENDPOINTS_INVALID: 'MINIAPP_MCP_ENDPOINTS_INVALID',
MINIAPP_HOST_CONNECTOR_ACCESS_INVALID: 'MINIAPP_CONNECTOR_ACCESS_INVALID',
WORKSPACE_INSTALL_FAILED: 'WORKSPACE_INSTALL_FAILED',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ import type {
MutatePluginResult as MutatePluginResp,
} from "@mavis/protocol/local";
import type { MiniAppSurfaceSummary } from "@mavis/shared/miniapp-surface";
import type {
MiniAppAgentAgents,
MiniAppAgentModels,
MiniAppAgentModelSelection,
MiniAppAgentPermissionDecision,
MiniAppAgentRunEvent,
MiniAppAgentRunResult,
} from "../../service/miniapp/index.js";
import type { SessionReportManifest } from "../../service/session-system/index.js";
import type { WorkspaceReviewFileContent, WorkspaceReviewFileDiff, WorkspaceReviewSearchResult, WorkspaceReviewSummary } from "../../service/workspace/contracts.js";
import type {
Expand All @@ -35,6 +43,13 @@ import type {
UserModelProviderCandidateView,
} from "../../service/model-system/index.js";

/** Trusted tab identity the Host resolved for a Mini App Agent call; never page-supplied. */
export interface ProcessLocalMiniAppAgentCaller {
readonly pluginId: string;
readonly runId: string;
readonly hostSessionId?: string;
}

export type ProcessLocalModelInput = Omit<UserModelInputView, "modalities"> & {
modalities?: { input?: readonly string[]; output?: readonly string[] };
};
Expand All @@ -46,6 +61,11 @@ export interface ProcessLocalMiniAppLaunch {
readonly origin: string;
readonly surfacePath: string;
readonly runId: string;
/**
* The credential the Mini App's own guard demands. Server-only, like the rest of this object: the
* proxy injects it, and no renderer and no published view ever carries it.
*/
readonly credential: string;
};
}

Expand Down Expand Up @@ -132,6 +152,15 @@ export interface LocalRuntimeApplication {
};
readonly miniApps?: {
list(): Promise<{ readonly miniApps: readonly MiniAppSurfaceSummary[] }>;
/**
* The same rows, including a package that is installed but disabled.
*
* `list` answers what the runtime can start; this answers what is installed,
* with `enabled` on each row, so a management surface can offer to turn a
* disabled Mini App back on. Kept apart rather than folded into `list` so no
* existing consumer starts seeing packages it cannot start.
*/
listInstalled(): Promise<{ readonly miniApps: readonly MiniAppSurfaceSummary[] }>;
open(input: {
readonly pluginId: string;
readonly signal?: AbortSignal;
Expand All @@ -141,6 +170,62 @@ export interface LocalRuntimeApplication {
readonly signal?: AbortSignal;
}): Promise<void>;
};
/**
* Page-path entry for the Mini App Agent API. The Host resolves `caller` from the tab that sent
* the request; the page payload never carries identity. `runId` is the launch `runId` (= process
* generation) and must match a registered arena; `hostSessionId` is the chat session the tab is
* bound to, when any.
*/
readonly miniAppAgent?: {
run(input: {
readonly caller: ProcessLocalMiniAppAgentCaller;
readonly text: string;
readonly sessionId?: string;
/** Only with a new session: the agent it is created for. */
readonly agent?: string;
/** Switches the session to this model before the turn. */
readonly model?: MiniAppAgentModelSelection;
readonly signal?: AbortSignal;
readonly onEvent?: (event: MiniAppAgentRunEvent) => void;
}): Promise<MiniAppAgentRunResult>;
models(input: { readonly caller: ProcessLocalMiniAppAgentCaller }): Promise<MiniAppAgentModels>;
agents(input: { readonly caller: ProcessLocalMiniAppAgentCaller }): Promise<MiniAppAgentAgents>;
/** Creates a session bound like a first `run` would, without running a turn. */
create(input: {
readonly caller: ProcessLocalMiniAppAgentCaller;
readonly agent?: string;
}): Promise<{ readonly sessionId: string }>;
reply(input: {
readonly caller: ProcessLocalMiniAppAgentCaller;
readonly sessionId: string;
readonly requestId: string;
readonly decision: MiniAppAgentPermissionDecision;
}): Promise<void>;
answer(input: {
readonly caller: ProcessLocalMiniAppAgentCaller;
readonly sessionId: string;
readonly requestId: string;
readonly answers: readonly AskQuestionnaireReplyAnswer[];
/** Switches the session to this model before the continuation turn. */
readonly model?: MiniAppAgentModelSelection;
readonly signal?: AbortSignal;
readonly onEvent?: (event: MiniAppAgentRunEvent) => void;
}): Promise<MiniAppAgentRunResult>;
/** Ownership + host check for showing a Mini App session in the host chat's side panel. */
open(input: {
readonly caller: ProcessLocalMiniAppAgentCaller;
readonly sessionId: string;
}): Promise<{ readonly sessionId: string; readonly hostSessionId: string }>;
/** Host reports the side panel now shows / no longer shows a Mini App session (after its UI accepted). */
panelOpened(input: {
readonly sessionId: string;
readonly hostSessionId: string;
}): Promise<void>;
panelClosed(input: {
readonly sessionId: string;
readonly hostSessionId: string;
}): Promise<void>;
};
readonly goals?: {
isEnabled(): boolean;
get(sessionId: string): Promise<GlobalThreadGoal | undefined>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,84 @@ describe("composeProcessLocalApplication workspace ownership", () => {
});
});

describe("composeProcessLocalApplication Mini App agent exposure", () => {
function compose(miniAppAgent: unknown) {
return composeProcessLocalApplication({
eventBus: { subscribe: vi.fn(() => () => undefined) },
usageCommits: undefined,
workspaceGit: {
getMetadata: vi.fn(),
getChanges: vi.fn(),
getReviewSummary: vi.fn(),
listReviewFileDiffs: vi.fn(),
getReviewFileContent: vi.fn(),
searchReviewDiffs: vi.fn(),
} as never,
compatibility: {
skills: { listSkills: vi.fn(), listRuntimeSkills: vi.fn() },
peripherals: {},
} as never,
listRuntimeSkills: vi.fn(),
plugins: {} as never,
pluginControl: {} as never,
miniApp: undefined,
miniAppAgent: miniAppAgent as never,
planEntryEnabled: () => false,
mcp: {} as never,
modelProvider: {
application: {} as never,
providers: {} as never,
listProviderPresets: vi.fn(),
oauth: {} as never,
},
});
}

function gatewayDouble() {
return {
bind: vi.fn(),
registerArena: vi.fn(),
panelOpened: vi.fn(async () => undefined),
panelClosed: vi.fn(async () => undefined),
subscribePermissionAsks: vi.fn(),
run: vi.fn(async () => ({ sessionId: "s", status: "finished" as const })),
models: vi.fn(async () => ({ models: [], default: { providerId: "p", modelId: "m" } })),
agents: vi.fn(async () => ({ agents: [] })),
create: vi.fn(async () => ({ sessionId: "s" })),
reply: vi.fn(async () => undefined),
answer: vi.fn(async () => ({ sessionId: "s", status: "finished" as const })),
open: vi.fn(async () => ({ sessionId: "s", hostSessionId: "chat" })),
};
}

it("publishes the client operations without the gateway or its ports", () => {
const application = compose(gatewayDouble());

expect(Object.keys(application.miniAppAgent ?? {}).sort()).toEqual([
"agents",
"answer",
"create",
"models",
"open",
"panelClosed",
"panelOpened",
"reply",
"run",
]);
for (const internal of ["bind", "registerArena", "ports", "subscribePermissionAsks"]) {
expect(application.miniAppAgent).not.toHaveProperty(internal);
}
// Nothing else on the facade grew a hole into the gateway.
expect(JSON.stringify(application)).not.toContain("registerArena");
});

it("publishes no agent API when the runtime has no gateway", () => {
const application = compose(undefined);

expect(application.miniAppAgent).toBeUndefined();
});
});

describe("SessionContentApplication history message serialization", () => {
it("keeps ordered activity parts in the opaque rawJson channel", () => {
const parts = [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type { V1ServiceCompatibility } from '../../compat/v1/runtime.js';
import { createProcessLocalApplication } from '../index.js';
import { createProcessLocalMiniAppApplication } from './process-local-miniapp-application.js';
import { createProcessLocalMiniAppAgentApplication } from './process-local-miniapp-agent-application.js';
import type { WorkspaceGitService } from '../../service/workspace/index.js';

type ProcessLocalApplicationOptions = Parameters<typeof createProcessLocalApplication>[0];
type ProcessLocalMiniAppArgs = Parameters<typeof createProcessLocalMiniAppApplication>;
type ProcessLocalMiniAppAgentGateway = Parameters<
typeof createProcessLocalMiniAppAgentApplication
>[0];

/**
* Wires the process-local Application facade over owners the runtime has already
Expand All @@ -27,13 +31,14 @@ export function composeProcessLocalApplication(input: {
readonly plugins: ProcessLocalApplicationOptions['plugins'];
readonly pluginControl: ProcessLocalMiniAppArgs[0];
readonly miniApp: ProcessLocalMiniAppArgs[1];
readonly miniAppAgent?: ProcessLocalMiniAppAgentGateway;
readonly planEntryEnabled: () => boolean;
readonly sessionReports?: ProcessLocalApplicationOptions['sessionReports'];
readonly instructions?: ProcessLocalApplicationOptions['instructions'];
readonly mcp: ProcessLocalApplicationOptions['peripherals']['mcp'];
readonly modelProvider: ProcessLocalApplicationOptions['modelProvider'];
}): ReturnType<typeof createProcessLocalApplication> {
return createProcessLocalApplication({
const application = createProcessLocalApplication({
eventBus: input.eventBus,
usageCommits: input.usageCommits,
skills: {
Expand Down Expand Up @@ -70,4 +75,11 @@ export function composeProcessLocalApplication(input: {
peripherals: { ...input.compatibility.peripherals, mcp: input.mcp },
modelProvider: input.modelProvider,
});
// The gateway is the runtime's private admission layer; only this projection reaches a client.
return input.miniAppAgent
? {
...application,
miniAppAgent: createProcessLocalMiniAppAgentApplication(input.miniAppAgent),
}
: application;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { MiniAppAgentCaller, MiniAppAgentGateway } from '../../service/miniapp/index.js';
import type { LocalRuntimeApplication } from './process-local-application-contract.js';

type ProcessLocalMiniAppAgentApplication = NonNullable<LocalRuntimeApplication['miniAppAgent']>;
type PageCaller = Parameters<ProcessLocalMiniAppAgentApplication['run']>[0]['caller'];

function toGatewayCaller(caller: PageCaller): MiniAppAgentCaller {
return {
pluginId: caller.pluginId,
processGeneration: caller.runId,
source: 'page',
...(caller.hostSessionId !== undefined ? { hostSessionId: caller.hostSessionId } : {}),
};
}

/** Page-path entry: the Electron bridge supplies the trusted tab identity, the gateway enforces policy. */
export function createProcessLocalMiniAppAgentApplication(
gateway: Pick<
MiniAppAgentGateway,
| 'run'
| 'reply'
| 'answer'
| 'open'
| 'panelOpened'
| 'panelClosed'
| 'models'
| 'agents'
| 'create'
>,
): ProcessLocalMiniAppAgentApplication {
return {
run: ({ caller, text, sessionId, agent, model, signal, onEvent }) =>
gateway.run(
toGatewayCaller(caller),
{
text,
...(sessionId !== undefined ? { sessionId } : {}),
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
},
{ ...(signal ? { signal } : {}), ...(onEvent ? { onEvent } : {}) },
),
models: ({ caller }) => gateway.models(toGatewayCaller(caller)),
agents: ({ caller }) => gateway.agents(toGatewayCaller(caller)),
create: ({ caller, agent }) =>
gateway.create(toGatewayCaller(caller), agent === undefined ? undefined : { agent }),
reply: ({ caller, sessionId, requestId, decision }) =>
gateway.reply(toGatewayCaller(caller), { sessionId, requestId, decision }),
answer: ({ caller, sessionId, requestId, answers, model, signal, onEvent }) =>
gateway.answer(
toGatewayCaller(caller),
{ sessionId, requestId, answers, ...(model !== undefined ? { model } : {}) },
{ ...(signal ? { signal } : {}), ...(onEvent ? { onEvent } : {}) },
),
open: ({ caller, sessionId }) => gateway.open(toGatewayCaller(caller), { sessionId }),
panelOpened: async ({ sessionId, hostSessionId }) =>
gateway.panelOpened({ sessionId, hostSessionId }),
panelClosed: async ({ sessionId, hostSessionId }) =>
gateway.panelClosed({ sessionId, hostSessionId }),
};
}
Loading
Loading