Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions .github/issue-evidence/11305-birdclaw-e2e-summary.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"failures": [],
"ok": true
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@
"@elizaos/security": "workspace:*",
"@elizaos/plugin-shell": "workspace:*",
"@elizaos/plugin-pty": "workspace:*",
"@elizaos/plugin-birdclaw": "workspace:*",
"@elizaos/plugin-worker-runtime": "workspace:*",
"@elizaos/plugin-scheduling": "workspace:*",
"@elizaos/plugin-signal": "workspace:*",
Expand Down
69 changes: 69 additions & 0 deletions packages/agent/src/api/views-registry.package-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Plugin package-dir resolution for view registration.
*
* A plugin's short name can collide with an unrelated published npm package
* (the concrete case: plugin "birdclaw" vs the `birdclaw` CLI on npm, which
* Bun can resolve from its install cache). The registry must prefer the
* canonical `@elizaos/plugin-<name>` package so the view bundle is served
* from the actual plugin directory, and must resolve a real workspace plugin
* end to end.
*/

import type { Plugin } from "@elizaos/core";
import { afterEach, describe, expect, it } from "vitest";
import {
listViews,
pluginPackageNameCandidates,
registerPluginViews,
unregisterPluginViews,
} from "./views-registry.js";

describe("pluginPackageNameCandidates", () => {
it("prefers the canonical @elizaos/plugin-* package over the bare short name", () => {
expect(pluginPackageNameCandidates("birdclaw")).toEqual([
"@elizaos/plugin-birdclaw",
"birdclaw",
]);
});

it("uses a scoped plugin name as-is", () => {
expect(pluginPackageNameCandidates("@elizaos/plugin-inbox")).toEqual([
"@elizaos/plugin-inbox",
]);
expect(pluginPackageNameCandidates("@acme/plugin-custom")).toEqual([
"@acme/plugin-custom",
]);
});
});

describe("registerPluginViews package-dir resolution", () => {
const PLUGIN_NAME = "birdclaw";

afterEach(() => {
unregisterPluginViews(PLUGIN_NAME);
});

it("resolves a short-named workspace plugin to its plugins/plugin-<name> dir", async () => {
const plugin: Plugin = {
name: PLUGIN_NAME,
description: "resolution fixture",
views: [
{
id: "birdclaw-resolution-fixture",
label: "Birdclaw fixture",
bundlePath: "dist/views/bundle.js",
},
],
} as Plugin;

await registerPluginViews(plugin);

const entry = listViews({ includeAllKinds: true }).find(
(view) => view.id === "birdclaw-resolution-fixture",
);
expect(entry).toBeDefined();
// Normalized so the assertion holds on Windows path separators too.
const pluginDir = (entry?.pluginDir ?? "").split("\\").join("/");
expect(pluginDir).toContain("plugins/plugin-birdclaw");
});
});
18 changes: 15 additions & 3 deletions packages/agent/src/api/views-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,20 @@ const registry = new Map<string, ViewRegistryEntry>();
/** View ids already warned about for oversized bundles — warn once per process. */
const warnedLargeBundles = new Set<string>();

/**
* Package names to probe for a plugin, in preference order. The canonical
* `@elizaos/plugin-<name>` candidate comes BEFORE the bare short name: a
* plugin's short name can collide with an unrelated published npm package
* (e.g. plugin "birdclaw" vs the `birdclaw` CLI on npm), and under Bun a
* bare-name resolve can hit that package's install cache — registering the
* view against a directory that isn't this plugin at all.
*/
export function pluginPackageNameCandidates(pluginName: string): string[] {
return pluginName.startsWith("@")
? [pluginName]
: [`@elizaos/plugin-${pluginName}`, pluginName];
}

/**
* Attempt to resolve the package root dir for a plugin by name using
* `require.resolve`. Returns `undefined` when the package is not reachable
Expand All @@ -70,9 +84,7 @@ async function resolvePluginPackageDir(
): Promise<string | undefined> {
const { createRequire } = await import("node:module");
const req = createRequire(import.meta.url);
const packageNames = pluginName.startsWith("@")
? [pluginName]
: [pluginName, `@elizaos/plugin-${pluginName}`];
const packageNames = pluginPackageNameCandidates(pluginName);

for (const packageName of packageNames) {
// Preferred: resolve the package's own package.json directly. Requires the
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/config/zod-schema.agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ export const AgentEntrySchema = z
advancedMemory: z.boolean().optional(),
agentOrchestrator: z.boolean().optional(),
gitpathologist: z.boolean().optional(),
birdclaw: z.boolean().optional(),
humanDelay: HumanDelaySchema.optional(),
heartbeat: HeartbeatSchema,
identity: IdentitySchema,
Expand Down Expand Up @@ -735,6 +736,7 @@ export const AgentDefaultsSchema = z
advancedMemory: z.boolean().optional(),
agentOrchestrator: z.boolean().optional(),
gitpathologist: z.boolean().optional(),
birdclaw: z.boolean().optional(),
contextPruning: z
.object({
mode: z.union([z.literal("off"), z.literal("cache-ttl")]).optional(),
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/external-modules.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ declare module "@elizaos/plugin-ollama";
declare module "@elizaos/plugin-openai";
declare module "@elizaos/plugin-shell";
declare module "@elizaos/plugin-pty";
declare module "@elizaos/plugin-birdclaw";
declare module "@elizaos/plugin-x402" {
import type {
IAgentRuntime,
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/runtime/core-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ export const OPTIONAL_CORE_PLUGINS: readonly string[] = [
"@elizaos/plugin-elevenlabs", // ElevenLabs text-to-speech
"@elizaos/plugin-music", // Library, playback, and streaming routes.
"@elizaos/plugin-gitpathologist", // forensic git-history analysis (opt-in via ELIZA_GITPATHOLOGIST, auto-on when .git/ exists)
"@elizaos/plugin-birdclaw", // birdclaw.sh local-first Twitter/X archive (auto-on when the birdclaw CLI/data root exists, gate ELIZA_BIRDCLAW)
// "@elizaos/plugin-directives", // directive processing remains opt-in
// "@elizaos/plugin-mcp", // MCP protocol support remains opt-in
// @elizaos/plugin-scheduling is now an always-loaded CORE + MOBILE plugin.
Expand Down
12 changes: 12 additions & 0 deletions packages/agent/src/runtime/eliza.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,9 @@ const loadOptionalPlugin = async (packageName: string): Promise<unknown> => {
if (packageName === "@elizaos/plugin-pty") {
return await import(/* @vite-ignore */ "@elizaos/plugin-pty");
}
if (packageName === "@elizaos/plugin-birdclaw") {
return await import(/* @vite-ignore */ "@elizaos/plugin-birdclaw");
}
if (packageName === "@elizaos/plugin-ollama") {
return await import(/* @vite-ignore */ "@elizaos/plugin-ollama");
}
Expand Down Expand Up @@ -492,6 +495,15 @@ const CORE_STATIC_PLUGIN_REGISTRATIONS: readonly CoreStaticPluginRegistration[]
required: false,
load: () => getOptionalPlugin("@elizaos/plugin-pty"),
},
{
// Auto-on only when the host has the birdclaw CLI or an existing
// ~/.birdclaw data root (see birdclawRequested in plugin-collector.ts).
// Registers BIRDCLAW_SERVICE + the local Twitter/X archive view/action.
packageName: "@elizaos/plugin-birdclaw",
phase: "deferred",
required: false,
load: () => getOptionalPlugin("@elizaos/plugin-birdclaw"),
},
{
packageName: "@elizaos/plugin-commands",
phase: "deferred",
Expand Down
86 changes: 86 additions & 0 deletions packages/agent/src/runtime/plugin-collector-birdclaw.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import type { ElizaConfig } from "../config/config.ts";
import { collectPluginNames } from "./plugin-collector.ts";

const BIRDCLAW = "@elizaos/plugin-birdclaw";

const ENV_KEYS = [
"ELIZA_PLATFORM",
"ELIZA_BIRDCLAW",
"BIRDCLAW_BIN",
"BIRDCLAW_HOME",
"HOME",
"PATH",
] as const;

let savedEnv: Record<string, string | undefined>;

beforeEach(() => {
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
// A hermetic host: no birdclaw binary on PATH, no ~/.birdclaw, no overrides.
delete process.env.ELIZA_BIRDCLAW;
delete process.env.BIRDCLAW_BIN;
delete process.env.BIRDCLAW_HOME;
process.env.HOME = "/nonexistent-home-for-birdclaw-test";
process.env.PATH = "/nonexistent-bin-for-birdclaw-test";
});

afterEach(() => {
for (const k of ENV_KEYS) {
const v = savedEnv[k];
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});

describe("collectPluginNames birdclaw gate", () => {
it("stays off when the host has no birdclaw binary or data root", () => {
const names = collectPluginNames({} as ElizaConfig);
expect(names.has(BIRDCLAW)).toBe(false);
});

it("loads on ELIZA_BIRDCLAW=1 even without auto-detection", () => {
process.env.ELIZA_BIRDCLAW = "1";
const names = collectPluginNames({} as ElizaConfig);
expect(names.has(BIRDCLAW)).toBe(true);
});

it("stays off on ELIZA_BIRDCLAW=0 even when a data root exists", () => {
process.env.ELIZA_BIRDCLAW = "0";
// Point BIRDCLAW_HOME at a directory that certainly exists.
process.env.BIRDCLAW_HOME = process.cwd();
const names = collectPluginNames({} as ElizaConfig);
expect(names.has(BIRDCLAW)).toBe(false);
});

it("auto-loads when BIRDCLAW_HOME points at an existing data root", () => {
process.env.BIRDCLAW_HOME = process.cwd();
const names = collectPluginNames({} as ElizaConfig);
expect(names.has(BIRDCLAW)).toBe(true);
});

it("config birdclaw:true wins over a missing host install", () => {
const config = {
agents: { defaults: { birdclaw: true } },
} as unknown as ElizaConfig;
const names = collectPluginNames(config);
expect(names.has(BIRDCLAW)).toBe(true);
});

it("config birdclaw:false wins over auto-detection", () => {
process.env.BIRDCLAW_HOME = process.cwd();
const config = {
agents: { defaults: { birdclaw: false } },
} as unknown as ElizaConfig;
const names = collectPluginNames(config);
expect(names.has(BIRDCLAW)).toBe(false);
});

it("never loads on mobile even when forced by env", () => {
process.env.ELIZA_PLATFORM = "android";
process.env.ELIZA_BIRDCLAW = "1";
const names = collectPluginNames({} as ElizaConfig);
expect(names.has(BIRDCLAW)).toBe(false);
});
});
46 changes: 46 additions & 0 deletions packages/agent/src/runtime/plugin-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,41 @@ function gitpathologistRequested(config: ElizaConfig): boolean {
return existsSync(path.join(resolveGitpathologistRepoRoot(), ".git"));
}

/**
* Birdclaw (@elizaos/plugin-birdclaw) wraps the birdclaw CLI — a local-first
* Twitter/X archive (https://birdclaw.sh). Auto-loads when the host actually
* has birdclaw: the `birdclaw` binary on PATH, a `BIRDCLAW_BIN`/`BIRDCLAW_HOME`
* override, or an existing `~/.birdclaw` data root. Users can force it either
* way via config `birdclaw: true|false` or ELIZA_BIRDCLAW=1/0.
*/
function birdclawBinaryOnPath(): boolean {
const rawPath = process.env.PATH;
if (!rawPath) return false;
for (const dir of rawPath.split(path.delimiter)) {
if (!dir) continue;
if (existsSync(path.join(dir, "birdclaw"))) return true;
}
return false;
}

function birdclawRequested(config: ElizaConfig): boolean {
const agentEntry = config.agents?.list?.[0];
const fromEntry = agentEntry?.birdclaw;
const fromDefaults = config.agents?.defaults?.birdclaw;
if (typeof fromEntry === "boolean") return fromEntry;
if (typeof fromDefaults === "boolean") return fromDefaults;
const raw = process.env.ELIZA_BIRDCLAW?.trim().toLowerCase();
if (raw === "0" || raw === "false" || raw === "no") return false;
if (raw === "1" || raw === "true" || raw === "yes") return true;
const bin = process.env.BIRDCLAW_BIN?.trim();
if (bin && existsSync(bin)) return true;
const home = process.env.BIRDCLAW_HOME?.trim();
if (home && existsSync(home)) return true;
const userHome = process.env.HOME?.trim();
if (userHome && existsSync(path.join(userHome, ".birdclaw"))) return true;
return birdclawBinaryOnPath();
}

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -463,6 +498,17 @@ export function collectPluginNames(
"gitpathologist (auto-on when .git/ present; gate ELIZA_GITPATHOLOGIST)",
);
}
// Mobile never gets birdclaw: the plugin shells out to the birdclaw CLI,
// which cannot exist inside a store-build sandbox — gating the whole plugin
// (not just spawning) keeps its launcher tile from appearing where the
// archive can never load.
if (!onMobile && birdclawRequested(config)) {
pluginsToLoad.add("@elizaos/plugin-birdclaw");
track(
"@elizaos/plugin-birdclaw",
"birdclaw (auto-on when the birdclaw CLI/data root is present; gate ELIZA_BIRDCLAW)",
);
}
// Allow list is additive — extra plugins on top of auto-detection,
// not an exclusive whitelist that blocks everything else.
if (allowList && allowList.length > 0) {
Expand Down
Loading
Loading