Compare commits

..
10 changed files with 168 additions and 235 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
@@ -0,0 +1,91 @@
package sh.paseo.diagnosis;
import android.content.Context;
import android.content.res.Configuration;
import android.text.Layout;
import android.text.Spannable;
import android.util.Log;
import android.view.View;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.common.mapbuffer.WritableMapBuffer;
import com.facebook.react.soloader.OpenSourceMergedSoMapping;
import com.facebook.react.uimanager.DisplayMetricsHolder;
import com.facebook.react.uimanager.PixelUtil;
import com.facebook.react.uimanager.ThemedReactContext;
import com.facebook.react.views.text.ReactTextUpdate;
import com.facebook.react.views.text.ReactTextView;
import com.facebook.react.views.text.TextLayoutManager;
import com.facebook.soloader.SoLoader;
import com.facebook.yoga.YogaMeasureMode;
import com.facebook.yoga.YogaMeasureOutput;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/** Native reproduction for #4368. Uses the measurement and rendering paths used by RN Text. */
@RunWith(AndroidJUnit4.class)
public class AboutFontWeightTest {
@Test public void ordinaryVersionFitsItsMeasuredBox() { checkVersion(0); }
@Test public void boldVersionFitsItsMeasuredBox() { checkVersion(300); }
private void checkVersion(int adjustment) {
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
Context app = InstrumentationRegistry.getInstrumentation().getTargetContext();
Configuration configuration = new Configuration(app.getResources().getConfiguration());
configuration.fontWeightAdjustment = adjustment;
Context configured = app.createConfigurationContext(configuration);
try {
SoLoader.init(app, OpenSourceMergedSoMapping.INSTANCE);
} catch (java.io.IOException error) {
throw new AssertionError("Could not initialize React Native", error);
}
DisplayMetricsHolder.initDisplayMetrics(configured);
ThemedReactContext context = new ThemedReactContext(new BridgeReactContext(configured), configured);
WritableMapBuffer attributes = new WritableMapBuffer();
attributes.put(4, 15.0); // TextAttributeProps.TA_KEY_FONT_SIZE
attributes.put(9, true); // TextAttributeProps.TA_KEY_ALLOW_FONT_SCALING
WritableMapBuffer fragment = new WritableMapBuffer();
fragment.put(0, "v0.12.0");
fragment.put(1, 1);
fragment.put(5, attributes);
WritableMapBuffer fragments = new WritableMapBuffer();
fragments.put(0, fragment);
WritableMapBuffer text = new WritableMapBuffer();
text.put(1, "v0.12.0");
text.put(2, fragments);
text.put(4, attributes);
WritableMapBuffer paragraph = new WritableMapBuffer();
paragraph.put(2, "highQuality");
paragraph.put(5, "none");
long measured = TextLayoutManager.measureText(context, text, paragraph,
1000, YogaMeasureMode.AT_MOST, 1000, YogaMeasureMode.AT_MOST, null, null);
int width = (int) Math.ceil(PixelUtil.toPixelFromDIP(YogaMeasureOutput.getWidth(measured)));
int height = (int) Math.ceil(PixelUtil.toPixelFromDIP(YogaMeasureOutput.getHeight(measured)));
Spannable content = TextLayoutManager.INSTANCE.getOrCreateSpannableForText(context, text, null);
ReactTextView view = new ReactTextView(context);
view.setId(2); // Fabric tag, matching the app's new-architecture Text path.
view.setText(new ReactTextUpdate(content, -1, false, 0,
Layout.BREAK_STRATEGY_HIGH_QUALITY, Layout.JUSTIFICATION_MODE_NONE));
view.measure(exact(width), exact(height));
view.layout(0, 0, width, height);
Layout rendered = view.getLayout();
Log.i("AboutFontWeightTest", "adjustment=" + adjustment + " box=" + width + "x" + height
+ " lines=" + rendered.getLineCount() + " lineWidth=" + rendered.getLineWidth(0)
+ " weight=" + view.getTypeface().getWeight());
assertEquals("The complete version should fit on its measured line", 1, rendered.getLineCount());
assertTrue("The last digit must remain inside the allocated width",
rendered.getLineWidth(0) <= width);
assertTrue("The complete line must remain inside the allocated height",
rendered.getLineBottom(0) <= height);
});
}
private static int exact(int size) {
return View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY);
}
}
@@ -0,0 +1,66 @@
# About version clipping reproduction ([#4368](https://github.com/getpaseo/paseo/issues/4368))
This is a diagnostic branch, with no production fix. The Android test compares
React Native's measured box for `v0.12.0` with the native TextView layout that
renders the same attributed text. It runs both ordinary and system-bold text.
It does not turn off accessibility scaling or bold text.
## Run the native test
From the repository root, prepare a development Android project if necessary:
```sh
cd packages/app
APP_VARIANT=development npx expo prebuild --platform android --no-install
```
In generated `android/app/build.gradle`, add:
```groovy
android.defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
dependencies {
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
}
```
Copy this test into the generated test source set, then build the app and tests:
```sh
mkdir -p android/app/src/androidTest/java/sh/paseo/diagnosis
cp e2e/mobile/about-font-size/AboutFontWeightTest.java android/app/src/androidTest/java/sh/paseo/diagnosis/
cd android
APP_VARIANT=development ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest \
-PreactNativeArchitectures=x86_64 --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
adb shell am instrument -w -e class sh.paseo.diagnosis.AboutFontWeightTest \
sh.paseo.debug.test/androidx.test.runner.AndroidJUnitRunner
```
The test requires Android 12 or later for `fontWeightAdjustment`. The native
contexts set that adjustment to 0 and 300 independently; they do not change the
device setting. The selected device's density and font scale still apply.
On Android 16 at 540 dpi and 2× font scale, both contexts receive a 301×123-pixel
box. The ordinary-weight control passes, with one rendered line. The bold test
fails with two rendered lines, so the final digit falls outside the allocated
single-line height. This isolates the measurement/rendering disagreement from
the About row's flex layout. A native dependency/build decision is still needed
before implementing a fix; changing an About-row style would leave the producer
of the incorrect measurement in place.
## UI comparison
Observed on Android 16, with current main's JavaScript and a freshly built native
development app: open Settings → About with 2× system font scale and 540 dpi on
a 1080-pixel-wide emulator. With system bold text, the displayed app version
loses its final digit (`v0.12.`). At the same display and text sizes without
system bold, the complete `v0.12.0` is visible. Cold-launch after configuration
changes before comparing; changing font scale in a running app also produced
temporary vertical clipping, which disappeared after a cold launch.
This substitutes standard Android accessibility settings for Samsung Easy Mode.
It verifies the truncated-version symptom, not Samsung's complete UI or the
already-fixed home-screen overlap. The reporter used Paseo 0.7.2; this branch
uses main `dd244c076d5f17d56d5592c7fb173dc7fdc2bbae`.
@@ -9,7 +9,7 @@ import type { PaseoToolCatalog } from "./tools/types.js";
export interface AgentProviderRuntime {
snapshotManager: ProviderSnapshotManager;
setPaseoToolCatalog(catalog: Pick<PaseoToolCatalog, "tools"> | null): void;
setPaseoToolCatalog(catalog: PaseoToolCatalog | null): void;
shutdown(): Promise<void>;
}
@@ -13,11 +13,6 @@ import {
getRealProviderRuntimeSettings,
} from "../../daemon-e2e/real-provider-test-config.js";
import type { PaseoToolCatalog } from "../tools/types.js";
import { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.js";
import { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../tools/paseo-tools.js";
import { serializePaseoToolInputParameters } from "../tools/paseo-tool-serialization.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js";
import { OpenCodeBridge } from "./opencode/bridge.js";
@@ -28,61 +23,6 @@ import {
requireSessionId,
} from "./opencode/test-utils/v2-local-e2e-helpers.js";
test("real OpenCode server advertises agent-scoped notification descriptions", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-manifest-real-"));
const logger = createTestLogger();
const providerSnapshotManager = new ProviderSnapshotManager({ logger });
const dependencies = {
agentManager: new AgentManager({ logger }),
agentStorage: new AgentStorage(path.join(root, "agents"), logger),
providerSnapshotManager,
logger,
};
const bridge = new OpenCodeBridge({ paseoHome: root, logger });
bridge.setManifestCatalog(createPaseoAgentToolManifest(dependencies));
const manager = new OpenCodeServerManager({
logger,
resolveHomeDir: () => root,
runtimeSettings: {
env: {
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_CACHE_HOME: path.join(root, "cache"),
OPENCODE_DISABLE_AUTO_UPDATE: "1",
},
},
decorateServerEnv: (env) => bridge.decorateServerEnv(env),
});
let inspection: Awaited<ReturnType<OpenCodeServerManager["acquireCurrent"]>> | undefined;
try {
await bridge.start();
inspection = await manager.acquireCurrent();
const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: root });
const response = await sdk.tool.list({ directory: root, provider: "openai", model: "gpt-5.4" });
expect(response.error).toBeUndefined();
const agentCatalog = createPaseoToolCatalog({ ...dependencies, callerAgentId: "caller" });
for (const name of ["create_agent", "send_agent_prompt"]) {
const actual = response.data!.find((tool) => tool.id === `paseo_${name}`)!;
expect(actual).toBeDefined();
const expected = serializePaseoToolInputParameters(agentCatalog.getTool(name)!);
expect(actual.parameters).toMatchObject({
properties: {
notifyOnFinish: {
description: (expected.properties as Record<string, { description: string }>)
.notifyOnFinish.description,
},
},
});
}
} finally {
await inspection?.release();
await manager.shutdown();
await bridge.close();
await providerSnapshotManager.shutdown();
await rm(root, { recursive: true, force: true });
}
}, 120_000);
test("real OpenCode server persists provider permissions across creation and resume", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-"));
const cwd = path.join(root, "repo");
@@ -5,18 +5,15 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { z } from "zod";
import Ajv from "ajv";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { PaseoToolCatalog } from "../../tools/types.js";
import { PaseoToolInputError } from "../../tools/types.js";
import { createPaseoToolCatalog } from "../../tools/paseo-tools.js";
import { AgentManager } from "../../agent-manager.js";
import { AgentStorage } from "../../agent-storage.js";
import { ProviderSnapshotManager } from "../../provider-snapshot-manager.js";
import { createTestAgentClients } from "../../../test-utils/fake-agent-client.js";
import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../../tools/paseo-tools.js";
import { serializePaseoToolInputParameters } from "../../tools/paseo-tool-serialization.js";
import {
OpenCodeBridge,
loadOpenCodeBridgePluginArtifact,
@@ -75,144 +72,6 @@ function readPluginOptions(env: Record<string, string>): {
}
describe("OpenCodeBridge", () => {
test("advertises agent-scoped schemas that execute through both bridge plugins", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-manifest-"));
temporaryDirectories.push(paseoHome);
const logger = createTestLogger();
const clients = createTestAgentClients();
const agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger);
const agentManager = new AgentManager({ clients, registry: agentStorage, logger });
const providerSnapshotManager = new ProviderSnapshotManager({ logger, extraClients: clients });
const dependencies = { agentManager, agentStorage, providerSnapshotManager, logger };
const bridge = new OpenCodeBridge({ paseoHome, logger });
await bridge.start();
bridge.setManifestCatalog(createPaseoAgentToolManifest(dependencies));
try {
const parent = await agentManager.createAgent(
{ provider: "codex", cwd: paseoHome },
undefined,
{ workspaceId: "manifest-workspace" },
);
const catalog = createPaseoToolCatalog({ ...dependencies, callerAgentId: parent.id });
bridge.bindSession({ sessionId: "bound", env: {}, tools: catalog });
const plugin = readPluginOptions(bridge.decorateServerEnv({}));
const response = await fetch(`${plugin.baseUrl}/_internal/opencode/tools`, {
headers: { Authorization: `Bearer ${plugin.token}` },
});
expect(response.status).toBe(200);
const manifest = z
.object({
tools: z.array(
z.object({
name: z.string(),
description: z.string(),
inputSchema: z.record(z.string(), z.unknown()),
}),
),
})
.parse(await response.json());
const definitions = new Map(manifest.tools.map((tool) => [tool.name, tool]));
const createDefinition = definitions.get("create_agent")!;
const sendDefinition = definitions.get("send_agent_prompt")!;
const topLevelCatalog = createPaseoToolCatalog(dependencies);
for (const name of ["create_agent", "send_agent_prompt"]) {
expect(serializePaseoToolInputParameters(topLevelCatalog.getTool(name)!)).toMatchObject({
properties: { notifyOnFinish: { default: false } },
});
}
const ajv = new Ajv({ useDefaults: true, strict: false });
const createInput = {
title: "Bridge child",
provider: "codex/gpt-5.4",
initialPrompt: "Hello",
notifyOnFinish: false,
};
expect(ajv.compile(createDefinition.inputSchema)(createInput)).toBe(true);
const v2Config = z
.object({ plugins: z.array(z.object({ package: z.string() })) })
.parse(JSON.parse((await bridge.decorateV2ServerEnv({})).OPENCODE_CONFIG_CONTENT));
const v2Module: {
default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> };
} = await import(
pathToFileURL(path.join(fileURLToPath(v2Config.plugins[0]!.package), "server.js")).href
);
const tools = new Map<string, V2TestTool>();
const dispose = await v2Module.default.setup({
options: plugin,
tool: {
transform: async (transform) => {
transform({
add: (tool) => {
tools.set(tool.name, tool);
},
});
return { dispose: async () => undefined };
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "bound" }),
hook: async () => ({ dispose: async () => undefined }),
},
});
try {
const created = await tools
.get("paseo_create_agent")!
.execute(createInput, { sessionID: "bound" });
const children = agentManager.listAgents().filter((agent) => agent.id !== parent.id);
expect(children).toHaveLength(1);
const child = children[0]!;
expect(created).toMatchObject({
content: [{ type: "text", text: expect.stringContaining(child.id) }],
});
expect(child.workspaceId).toBe("manifest-workspace");
expect([...definitions.keys()]).toEqual([...catalog.tools.keys()]);
for (const definition of manifest.tools) {
const accepted = catalog.getTool(definition.name)!;
expect(definition.inputSchema).toEqual(serializePaseoToolInputParameters(accepted));
expect(definition.description).toBe(accepted.description);
expect(tools.get(`paseo_${definition.name}`)!.input).toEqual(definition.inputSchema);
}
expect(createDefinition.inputSchema).toMatchObject({
properties: { notifyOnFinish: { default: true } },
});
expect(createDefinition.inputSchema.properties).not.toHaveProperty("background");
expect(sendDefinition.inputSchema).toMatchObject({
properties: { notifyOnFinish: { default: true } },
});
await expect(
catalog.executeTool("create_agent", { ...createInput, background: true }),
).rejects.toThrow("background");
const v1Module = await import(plugin.pluginUrl);
const hooks = await v1Module.default({}, plugin);
expect(Object.keys(hooks.tool.paseo_create_agent.args)).toEqual(
Object.keys(
z.record(z.string(), z.unknown()).parse(createDefinition.inputSchema.properties),
),
);
const sendInput = { agentId: child.id, prompt: "Hello again", notifyOnFinish: false };
expect(ajv.compile(sendDefinition.inputSchema)(sendInput)).toBe(true);
await expect(
hooks.tool.paseo_send_agent_prompt.execute(sendInput, {
sessionID: "bound",
ask: async () => undefined,
}),
).resolves.toMatchObject({ output: expect.stringContaining('"success": true') });
} finally {
await dispose();
}
} finally {
await bridge.close();
await Promise.all(
agentManager.listAgents().map((agent) => agentManager.closeAgent(agent.id)),
);
await agentManager.flushForShutdown();
await providerSnapshotManager.shutdown();
}
});
test("loads packaged bundle bytes without invoking source compilation", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-artifact-"));
temporaryDirectories.push(root);
@@ -837,7 +696,6 @@ describe("OpenCodeBridge", () => {
interface V2TestTool {
name: string;
input?: unknown;
execute(input: unknown, call: { sessionID: string }): Promise<unknown>;
}
interface V2TestContext {
@@ -60,7 +60,7 @@ export class OpenCodeBridge {
private baseUrl: string | null = null;
private pluginUrl: string | null = null;
private v2PluginUrl: string | null = null;
private manifestCatalog: Pick<PaseoToolCatalog, "tools"> | null = null;
private manifestCatalog: PaseoToolCatalog | null = null;
constructor(options: OpenCodeBridgeOptions) {
this.paseoHome = options.paseoHome;
@@ -89,7 +89,7 @@ export class OpenCodeBridge {
this.baseUrl = `http://127.0.0.1:${address.port}`;
}
setManifestCatalog(catalog: Pick<PaseoToolCatalog, "tools"> | null): void {
setManifestCatalog(catalog: PaseoToolCatalog | null): void {
this.manifestCatalog = catalog;
}
@@ -637,20 +637,6 @@ function resolveTerminalKeyToken({ key, literal, preamble }: TerminalKeyTokenInp
}
export function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog {
return createToolCatalog(options, options.callerAgentId ? "agent" : "top-level");
}
export function createPaseoAgentToolManifest(
options: PaseoToolHostDependencies,
): Pick<PaseoToolCatalog, "tools"> {
// Plugins register before a caller exists, but every execution uses an agent-bound catalog.
return { tools: createToolCatalog(options, "agent").tools };
}
function createToolCatalog(
options: PaseoToolHostDependencies,
scope: "agent" | "top-level",
): PaseoToolCatalog {
const {
agentManager,
agentStorage,
@@ -1223,7 +1209,7 @@ function createToolCatalog(
.describe("Legacy GitHub PR number. Prefer workspace.source.target.githubPrNumber."),
};
const createAgentInputSchema = z
.object(scope === "agent" ? agentToAgentInputSchema : canonicalTopLevelInputSchema)
.object(callerAgentId ? agentToAgentInputSchema : canonicalTopLevelInputSchema)
.passthrough();
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict();
const legacyAgentToAgentCreateAgentArgsSchema = z.object(legacyAgentToAgentInputSchema).strict();
@@ -1259,8 +1245,9 @@ function createToolCatalog(
"Agent-scoped only: get notified when the prompted agent finishes, errors, or needs permission.",
),
};
const sendAgentPromptInputSchema =
scope === "agent" ? agentToAgentSendAgentPromptInputSchema : topLevelSendAgentPromptInputSchema;
const sendAgentPromptInputSchema = callerAgentId
? agentToAgentSendAgentPromptInputSchema
: topLevelSendAgentPromptInputSchema;
const inspectProviderInputSchema = {
provider: ProviderOrProviderModelInputSchema.describe(
"Provider ID, optionally with a model ID (for example codex or codex/gpt-5.4).",
@@ -2666,7 +2653,7 @@ function createToolCatalog(
.optional()
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
name: z.string().optional(),
provider: (scope === "agent" ? AgentProviderEnum.optional() : AgentProviderEnum).describe(
provider: (callerAgentId ? AgentProviderEnum.optional() : AgentProviderEnum).describe(
"Provider, or provider/model (for example: codex or codex/gpt-5.4). Defaults to the caller's provider in an agent-scoped session.",
),
cwd: z.string().optional(),
+1 -4
View File
@@ -137,7 +137,6 @@ import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import {
createPaseoToolCatalog,
createPaseoAgentToolManifest,
type PaseoToolHostDependencies,
} from "./agent/tools/paseo-tools.js";
import type { PaseoToolRuntimeContext } from "./agent/tools/types.js";
@@ -1458,9 +1457,7 @@ export async function createPaseoDaemon(
const createAgentToolCatalog = (runtime: PaseoToolRuntimeContext) =>
createPaseoToolCatalog(createAgentToolHostDependencies(runtime));
const setAgentProviderToolsEnabled = (enabled: boolean) => {
agentProviderRuntime.setPaseoToolCatalog(
enabled ? createPaseoAgentToolManifest(createAgentToolHostDependencies({})) : null,
);
agentProviderRuntime.setPaseoToolCatalog(enabled ? createAgentToolCatalog({}) : null);
};
agentManager.setPaseoToolCatalogFactory(createAgentToolCatalog);
agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false);
@@ -288,12 +288,6 @@ async function runScenario(modeId: "auto" | "auto-review") {
try {
writeMockCodexConfig(codexHome, mockServer.url);
await client.connect();
const permissionEvents = client.observeEvents([
"agent_permission_request",
"agent_permission_resolved",
]);
permissionEvents.subscribe({ snapshot: () => {}, update: () => {} });
await permissionEvents.ready;
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({