From 90f14a6ca5dddf0a44240986ca86e9bd238394ce Mon Sep 17 00:00:00 2001 From: prateek Date: Sat, 14 Feb 2026 16:29:59 +0530 Subject: [PATCH] feat: notifier-composio plugin + integration tests for all plugins (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement notifier and terminal plugins (desktop, slack, webhook, iterm2, web) Implement all 5 notification and terminal UI plugins for the agent orchestrator, replacing stub files with full implementations of the Notifier and Terminal interfaces from @agent-orchestrator/core. Notifier plugins: - notifier-desktop: OS notifications via osascript (macOS) / notify-send (Linux) with priority-based sound (urgent=sound, others=silent) - notifier-slack: Slack Incoming Webhooks with Block Kit formatting, action buttons, PR links, CI status context blocks - notifier-webhook: Generic HTTP POST with JSON payloads, configurable headers, and retry with backoff (default 2 retries) Terminal plugins: - terminal-iterm2: AppleScript-based iTerm2 tab management — detects existing tabs by profile name, creates/reuses tabs (ported from scripts/open-iterm-tab reference implementation) - terminal-web: Web terminal session tracking for the dashboard's xterm.js frontend, providing URL generation and open-state tracking All plugins follow the PluginModule pattern (manifest + create export) and pass typecheck cleanly. Co-Authored-By: Claude Opus 4.6 * test: add comprehensive vitest suites for all notifier and terminal plugins Add 96 unit tests across 5 plugin packages covering: - notifier-desktop: priority-based sound mapping, osascript/notify-send command generation, platform detection (macOS/Linux/unsupported), title formatting, error propagation - notifier-slack: Block Kit message structure (header/section/context/ divider blocks), priority emoji mapping, PR link and CI status rendering, action button generation (URL and callback variants), channel routing, post method - notifier-webhook: JSON payload serialization, custom headers, retry logic (success after retry, exhausted retries, zero retries, network errors), timestamp ISO serialization - terminal-iterm2: AppleScript command generation, tab reuse via profile name detection, new tab creation with tmux attach, runtimeHandle preference over session ID, batch openAll with delays, error fallback - terminal-web: session open tracking, dashboard URL configuration, openAll batch registration, independent state per instance Co-Authored-By: Claude Opus 4.6 * fix: address PR review feedback — security, retry logic, side effects - Add AppleScript injection escaping in terminal-iterm2 and notifier-desktop - Webhook: only retry on 429/5xx (not 4xx), add exponential backoff - terminal-iterm2: fix isSessionOpen selecting tab (side effect), remove dead openNewWindow - notifier-slack: type-guard event.data access, add URL validation - notifier-webhook: add URL validation, remove unused config interfaces - Update all tests to cover new behaviors (108 tests passing) Co-Authored-By: Claude Opus 4.6 * fix: codex review — shell injection, iTerm2 name lookup, notify-send args, retry validation - terminal-iterm2: shell-escape session names in tmux command (single-quote wrapping) - terminal-iterm2: use `name of aSession` instead of `profile name` for tab lookup - notifier-desktop: fix notify-send arg order (options before title/body) - notifier-webhook: clamp retries/retryDelayMs to safe values (non-negative, finite) - notifier-slack: sanitize action_id to [a-z0-9_] characters only Co-Authored-By: Claude Opus 4.6 * chore: lint/format compliance after rebase on main - Fix ESLint errors: remove unused WebTerminalConfig, ignore next-env.d.ts - Run prettier on all files for consistent formatting - Update pnpm-lock.yaml with new lint dependencies Co-Authored-By: Claude Opus 4.6 * fix: codex review round 2 — printf injection, platform guard, config validation - terminal-iterm2: use shell-escaped name in printf title (not just tmux target) - terminal-iterm2: add platform guard — no-op with warning on non-macOS - notifier-webhook: validate customHeaders are string:string before spreading - notifier-desktop: validate sound config as boolean (reject string "false") Co-Authored-By: Claude Opus 4.6 * chore: update lockfile after rebase on main Co-Authored-By: Claude Opus 4.6 * feat: add notifier-composio plugin and integration tests for all plugins Add a new notifier-composio plugin as the recommended notification transport using Composio's unified API for Slack, Discord, and Gmail. Create comprehensive integration tests (79 tests across 6 files) for all notifier and terminal plugins, mocking only I/O boundaries. Co-Authored-By: Claude Opus 4.6 * fix: address PR review feedback — security, retry logic, side effects - Shell injection fix: double-escape (shell + AppleScript) in iTerm2 printf/tmux - iTerm2 no-window crash: create window if none exists - Composio graceful degradation: warn instead of throw when SDK missing - Composio emailTo validation: require emailTo when defaultApp is gmail - AbortSignal.timeout() replaces manual AbortController + {once: true} listener - Discord channelName fallback for channel_id - Slack empty action_id fallback - Dashboard port: terminal-web default changed from 9847 to 3000 - escapeAppleScript deduplicated into core/utils.ts - Consistent vitest version (^3.0.0) for composio plugin - Remove duplicate eslint ignore entry - Integration tests updated for shared event-factory helper - Unit tests updated for new validation and escaping behavior Co-Authored-By: Claude Opus 4.6 * chore: update lockfile after rebase on main Co-Authored-By: Claude Opus 4.6 * fix: prevent double ao_ prefix in Slack action_id fallback Co-Authored-By: Claude Opus 4.6 * fix: decouple Linux urgency from sound config, deduplicate validateUrl - Linux --urgency=critical now driven by event priority, not sound config - Moved validateUrl to core/utils.ts, imported by slack and webhook plugins Co-Authored-By: Claude Opus 4.6 * fix: handle ESM ERR_MODULE_NOT_FOUND for composio-core detection Co-Authored-By: Claude Opus 4.6 * fix: codex review round 2 — printf injection, platform guard, config validation - Slack action_id: append index for uniqueness (two "Retry" buttons no longer collide) - Composio Gmail subject: extract GMAIL_SUBJECT constant so notify() and post() use the same value - Agent integration tests: require API key env vars before running to prevent CI timeout when secrets are not configured - Update lockfile after rebase on main Co-Authored-By: Claude Opus 4.6 * fix: parallelize integration tests and increase CI timeout - Remove singleFork: true from vitest config — all integration test files use unique session prefixes so they're safe to run concurrently - Increase CI timeout from 15 to 20 minutes as safety margin for agent tests that make real API calls Co-Authored-By: Claude Opus 4.6 * fix: prevent unhandled promise rejection after timeout in composio notifier Attach a no-op .catch() to the executeAction promise so that if the timeout fires first and the action later rejects, it doesn't trigger an unhandledRejection event. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/integration-tests.yml | 2 +- packages/core/src/config.ts | 10 +- packages/core/src/index.ts | 2 +- packages/core/src/plugin-registry.ts | 1 + packages/core/src/utils.ts | 18 + packages/integration-tests/package.json | 6 + .../src/agent-aider.integration.test.ts | 4 +- .../src/agent-claude-code.integration.test.ts | 3 +- .../src/agent-codex.integration.test.ts | 3 +- .../src/agent-opencode.integration.test.ts | 4 +- .../src/helpers/event-factory.ts | 67 ++ .../src/notifier-composio.integration.test.ts | 190 ++++ .../src/notifier-desktop.integration.test.ts | 210 ++++ .../src/notifier-slack.integration.test.ts | 295 ++++++ .../src/notifier-webhook.integration.test.ts | 312 ++++++ .../src/terminal-iterm2.integration.test.ts | 220 ++++ .../src/terminal-web.integration.test.ts | 105 ++ packages/integration-tests/vitest.config.ts | 5 - .../plugins/notifier-composio/package.json | 36 + .../notifier-composio/src/index.test.ts | 288 ++++++ .../plugins/notifier-composio/src/index.ts | 276 +++++ .../plugins/notifier-composio/tsconfig.json | 8 + .../plugins/notifier-desktop/package.json | 4 +- .../notifier-desktop/src/index.test.ts | 270 +++++ .../plugins/notifier-desktop/src/index.ts | 117 ++- packages/plugins/notifier-slack/package.json | 4 +- .../plugins/notifier-slack/src/index.test.ts | 388 +++++++ packages/plugins/notifier-slack/src/index.ts | 189 +++- .../plugins/notifier-webhook/package.json | 4 +- .../notifier-webhook/src/index.test.ts | 308 ++++++ .../plugins/notifier-webhook/src/index.ts | 176 +++- packages/plugins/terminal-iterm2/package.json | 4 +- .../plugins/terminal-iterm2/src/index.test.ts | 298 ++++++ packages/plugins/terminal-iterm2/src/index.ts | 179 +++- packages/plugins/terminal-web/package.json | 4 +- .../plugins/terminal-web/src/index.test.ts | 153 +++ packages/plugins/terminal-web/src/index.ts | 53 +- pnpm-lock.yaml | 976 +++++++++++++++++- 38 files changed, 5162 insertions(+), 30 deletions(-) create mode 100644 packages/integration-tests/src/helpers/event-factory.ts create mode 100644 packages/integration-tests/src/notifier-composio.integration.test.ts create mode 100644 packages/integration-tests/src/notifier-desktop.integration.test.ts create mode 100644 packages/integration-tests/src/notifier-slack.integration.test.ts create mode 100644 packages/integration-tests/src/notifier-webhook.integration.test.ts create mode 100644 packages/integration-tests/src/terminal-iterm2.integration.test.ts create mode 100644 packages/integration-tests/src/terminal-web.integration.test.ts create mode 100644 packages/plugins/notifier-composio/package.json create mode 100644 packages/plugins/notifier-composio/src/index.test.ts create mode 100644 packages/plugins/notifier-composio/src/index.ts create mode 100644 packages/plugins/notifier-composio/tsconfig.json create mode 100644 packages/plugins/notifier-desktop/src/index.test.ts create mode 100644 packages/plugins/notifier-slack/src/index.test.ts create mode 100644 packages/plugins/notifier-webhook/src/index.test.ts create mode 100644 packages/plugins/terminal-iterm2/src/index.test.ts create mode 100644 packages/plugins/terminal-web/src/index.test.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index a4be852a0..845b8d9d9 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -15,7 +15,7 @@ jobs: integration: name: Integration Tests runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 60e3d4491..9d5ebc52e 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -78,7 +78,7 @@ const DefaultPluginsSchema = z.object({ runtime: z.string().default("tmux"), agent: z.string().default("claude-code"), workspace: z.string().default("worktree"), - notifiers: z.array(z.string()).default(["desktop"]), + notifiers: z.array(z.string()).default(["composio", "desktop"]), }); const OrchestratorConfigSchema = z.object({ @@ -89,10 +89,10 @@ const OrchestratorConfigSchema = z.object({ projects: z.record(ProjectConfigSchema), notifiers: z.record(NotifierConfigSchema).default({}), notificationRouting: z.record(z.array(z.string())).default({ - urgent: ["desktop", "slack"], - action: ["desktop", "slack"], - warning: ["slack"], - info: ["slack"], + urgent: ["desktop", "composio"], + action: ["desktop", "composio"], + warning: ["composio"], + info: ["composio"], }), reactions: z.record(ReactionConfigSchema).default({}), }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 631024028..c95975e66 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -45,4 +45,4 @@ export { createLifecycleManager } from "./lifecycle-manager.js"; export type { LifecycleManagerDeps } from "./lifecycle-manager.js"; // Shared utilities -export { shellEscape } from "./utils.js"; +export { shellEscape, escapeAppleScript, validateUrl } from "./utils.js"; diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index 1352c8e5b..cb327c359 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -40,6 +40,7 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> = // SCM { slot: "scm", name: "github", pkg: "@agent-orchestrator/plugin-scm-github" }, // Notifiers + { slot: "notifier", name: "composio", pkg: "@agent-orchestrator/plugin-notifier-composio" }, { slot: "notifier", name: "desktop", pkg: "@agent-orchestrator/plugin-notifier-desktop" }, { slot: "notifier", name: "slack", pkg: "@agent-orchestrator/plugin-notifier-slack" }, { slot: "notifier", name: "webhook", pkg: "@agent-orchestrator/plugin-notifier-webhook" }, diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 7724829d9..ee52ecddb 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -11,3 +11,21 @@ export function shellEscape(arg: string): string { return "'" + arg.replace(/'/g, "'\\''") + "'"; } + +/** + * Escape a string for safe interpolation inside AppleScript double-quoted strings. + * Handles backslashes and double quotes which would otherwise break or inject. + */ +export function escapeAppleScript(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** + * Validate that a URL starts with http:// or https://. + * Throws with a descriptive error including the plugin label if invalid. + */ +export function validateUrl(url: string, label: string): void { + if (!url.startsWith("https://") && !url.startsWith("http://")) { + throw new Error(`[${label}] Invalid url: must be http(s), got "${url}"`); + } +} diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index ca15549a2..b14b8ce18 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -22,6 +22,12 @@ "@agent-orchestrator/plugin-tracker-linear": "workspace:*" }, "devDependencies": { + "@agent-orchestrator/plugin-notifier-desktop": "workspace:*", + "@agent-orchestrator/plugin-notifier-slack": "workspace:*", + "@agent-orchestrator/plugin-notifier-webhook": "workspace:*", + "@agent-orchestrator/plugin-notifier-composio": "workspace:*", + "@agent-orchestrator/plugin-terminal-iterm2": "workspace:*", + "@agent-orchestrator/plugin-terminal-web": "workspace:*", "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" diff --git a/packages/integration-tests/src/agent-aider.integration.test.ts b/packages/integration-tests/src/agent-aider.integration.test.ts index f4f7785e5..13c72c6ee 100644 --- a/packages/integration-tests/src/agent-aider.integration.test.ts +++ b/packages/integration-tests/src/agent-aider.integration.test.ts @@ -72,7 +72,9 @@ async function canAiderConnect(bin: string): Promise { const tmuxOk = await isTmuxAvailable(); const aiderBin = await findAiderBinary(); -const aiderReady = aiderBin !== null && (await canAiderConnect(aiderBin)); +const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY); +// Skip the expensive canAiderConnect probe if no API key is available +const aiderReady = hasApiKey && aiderBin !== null && (await canAiderConnect(aiderBin)); const canRun = tmuxOk && aiderReady; // --------------------------------------------------------------------------- diff --git a/packages/integration-tests/src/agent-claude-code.integration.test.ts b/packages/integration-tests/src/agent-claude-code.integration.test.ts index df0072db0..d933a985f 100644 --- a/packages/integration-tests/src/agent-claude-code.integration.test.ts +++ b/packages/integration-tests/src/agent-claude-code.integration.test.ts @@ -43,7 +43,8 @@ async function findClaudeBinary(): Promise { const tmuxOk = await isTmuxAvailable(); const claudeBin = await findClaudeBinary(); -const canRun = tmuxOk && claudeBin !== null; +const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY); +const canRun = tmuxOk && claudeBin !== null && hasApiKey; // --------------------------------------------------------------------------- // Tests diff --git a/packages/integration-tests/src/agent-codex.integration.test.ts b/packages/integration-tests/src/agent-codex.integration.test.ts index 7f7ab6fb1..b41650a9f 100644 --- a/packages/integration-tests/src/agent-codex.integration.test.ts +++ b/packages/integration-tests/src/agent-codex.integration.test.ts @@ -43,7 +43,8 @@ async function findCodexBinary(): Promise { const tmuxOk = await isTmuxAvailable(); const codexBin = await findCodexBinary(); -const canRun = tmuxOk && codexBin !== null; +const hasApiKey = Boolean(process.env.OPENAI_API_KEY); +const canRun = tmuxOk && codexBin !== null && hasApiKey; // --------------------------------------------------------------------------- // Tests diff --git a/packages/integration-tests/src/agent-opencode.integration.test.ts b/packages/integration-tests/src/agent-opencode.integration.test.ts index e83cb25a0..6ace3fd38 100644 --- a/packages/integration-tests/src/agent-opencode.integration.test.ts +++ b/packages/integration-tests/src/agent-opencode.integration.test.ts @@ -65,7 +65,9 @@ async function canOpencodeRun(bin: string): Promise { const tmuxOk = await isTmuxAvailable(); const opencodeBin = await findOpencodeBinary(); -const opencodeReady = opencodeBin !== null && (await canOpencodeRun(opencodeBin)); +const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY); +// Skip the expensive canOpencodeRun probe if no API key is available +const opencodeReady = hasApiKey && opencodeBin !== null && (await canOpencodeRun(opencodeBin)); const canRun = tmuxOk && opencodeReady; // --------------------------------------------------------------------------- diff --git a/packages/integration-tests/src/helpers/event-factory.ts b/packages/integration-tests/src/helpers/event-factory.ts new file mode 100644 index 000000000..01b9a274c --- /dev/null +++ b/packages/integration-tests/src/helpers/event-factory.ts @@ -0,0 +1,67 @@ +import type { + OrchestratorEvent, + Session, + NotifyAction, + EventPriority, + EventType, + SessionStatus, + ActivityState, +} from "@agent-orchestrator/core"; + +/** + * Create a test OrchestratorEvent with sensible defaults. + * Override any field as needed. + */ +export function makeEvent(overrides: Partial = {}): OrchestratorEvent { + return { + id: "evt-test-1", + type: "session.spawned" as EventType, + priority: "info" as EventPriority, + sessionId: "app-1", + projectId: "my-project", + timestamp: new Date("2025-06-15T12:00:00Z"), + message: "Session app-1 spawned successfully", + data: {}, + ...overrides, + }; +} + +/** + * Create a test Session with sensible defaults. + * Override any field as needed. + */ +export function makeSession(overrides: Partial = {}): Session { + return { + id: "app-1", + projectId: "my-project", + status: "working" as SessionStatus, + activity: "active" as ActivityState, + branch: "feat/test", + issueId: null, + pr: null, + workspacePath: "/tmp/workspace", + runtimeHandle: null, + agentInfo: null, + createdAt: new Date("2025-06-15T12:00:00Z"), + lastActivityAt: new Date("2025-06-15T12:00:00Z"), + metadata: {}, + ...overrides, + }; +} + +/** + * Create a set of test NotifyActions. + */ +export function makeActions(overrides?: Partial[]): NotifyAction[] { + const defaults: NotifyAction[] = [ + { label: "View PR", url: "https://github.com/org/repo/pull/42" }, + { label: "Kill Session", callbackEndpoint: "/api/sessions/app-1/kill" }, + ]; + + if (!overrides) return defaults; + + return overrides.map((o, i) => ({ + ...defaults[i % defaults.length], + ...o, + })); +} diff --git a/packages/integration-tests/src/notifier-composio.integration.test.ts b/packages/integration-tests/src/notifier-composio.integration.test.ts new file mode 100644 index 000000000..4584bd270 --- /dev/null +++ b/packages/integration-tests/src/notifier-composio.integration.test.ts @@ -0,0 +1,190 @@ +/** + * Integration tests for notifier-composio. + * + * Uses _clientOverride to inject a mock Composio client at the I/O boundary. + * Everything else runs for real: config parsing, tool slug routing, message formatting. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { NotifyAction } from "@agent-orchestrator/core"; +import composioPlugin from "@agent-orchestrator/plugin-notifier-composio"; +import { makeEvent } from "./helpers/event-factory.js"; + +const mockExecuteAction = vi.fn().mockResolvedValue({ successful: true }); +const mockClient = { executeAction: mockExecuteAction }; + +describe("notifier-composio integration", () => { + const originalEnv = process.env.COMPOSIO_API_KEY; + + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.COMPOSIO_API_KEY; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.COMPOSIO_API_KEY = originalEnv; + } else { + delete process.env.COMPOSIO_API_KEY; + } + }); + + describe("config -> tool slug routing", () => { + it("slack app routes to SLACK_SEND_MESSAGE with channel", async () => { + const notifier = composioPlugin.create({ + composioApiKey: "key", + defaultApp: "slack", + channelName: "#deploys", + _clientOverride: mockClient, + }); + await notifier.notify(makeEvent()); + + expect(mockExecuteAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "SLACK_SEND_MESSAGE", + params: expect.objectContaining({ + channel: "#deploys", + }), + }), + ); + }); + + it("discord app routes to DISCORD_SEND_MESSAGE with channel_id", async () => { + const notifier = composioPlugin.create({ + composioApiKey: "key", + defaultApp: "discord", + channelId: "1234567890", + _clientOverride: mockClient, + }); + await notifier.notify(makeEvent()); + + expect(mockExecuteAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "DISCORD_SEND_MESSAGE", + params: expect.objectContaining({ + channel_id: "1234567890", + }), + }), + ); + }); + + it("gmail app routes to GMAIL_SEND_EMAIL with to/subject/body", async () => { + const notifier = composioPlugin.create({ + composioApiKey: "key", + defaultApp: "gmail", + emailTo: "admin@example.com", + _clientOverride: mockClient, + }); + await notifier.notify(makeEvent()); + + expect(mockExecuteAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "GMAIL_SEND_EMAIL", + params: expect.objectContaining({ + to: "admin@example.com", + subject: "Agent Orchestrator Notification", + }), + }), + ); + }); + }); + + describe("message formatting pipeline", () => { + it("includes priority emoji, event type, session ID, and message", async () => { + const notifier = composioPlugin.create({ + composioApiKey: "key", + _clientOverride: mockClient, + }); + await notifier.notify( + makeEvent({ priority: "urgent", type: "ci.failing", sessionId: "app-5" }), + ); + + const text = mockExecuteAction.mock.calls[0][0].params.text as string; + expect(text).toContain("\u{1F6A8}"); // urgent emoji + expect(text).toContain("ci.failing"); + expect(text).toContain("app-5"); + }); + + it("includes PR URL when present in event data", async () => { + const notifier = composioPlugin.create({ + composioApiKey: "key", + _clientOverride: mockClient, + }); + await notifier.notify( + makeEvent({ data: { prUrl: "https://github.com/org/repo/pull/99" } }), + ); + + const text = mockExecuteAction.mock.calls[0][0].params.text as string; + expect(text).toContain("https://github.com/org/repo/pull/99"); + }); + + it("omits PR URL when not a string", async () => { + const notifier = composioPlugin.create({ + composioApiKey: "key", + _clientOverride: mockClient, + }); + await notifier.notify(makeEvent({ data: { prUrl: 123 } })); + + const text = mockExecuteAction.mock.calls[0][0].params.text as string; + expect(text).not.toContain("PR:"); + }); + }); + + describe("notifyWithActions pipeline", () => { + it("includes action labels and URLs in message text", async () => { + const notifier = composioPlugin.create({ + composioApiKey: "key", + _clientOverride: mockClient, + }); + const actions: NotifyAction[] = [ + { label: "Merge PR", url: "https://github.com/merge" }, + { label: "Kill Session", callbackEndpoint: "/api/kill" }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const text = mockExecuteAction.mock.calls[0][0].params.text as string; + expect(text).toContain("Merge PR: https://github.com/merge"); + expect(text).toContain("- Kill Session"); + }); + }); + + describe("post pipeline", () => { + it("sends plain text with channel override", async () => { + const notifier = composioPlugin.create({ + composioApiKey: "key", + channelName: "#default", + _clientOverride: mockClient, + }); + await notifier.post!("All sessions complete", { channel: "#override" }); + + const args = mockExecuteAction.mock.calls[0][0].params; + expect(args.text).toBe("All sessions complete"); + expect(args.channel).toBe("#override"); + }); + + it("returns null", async () => { + const notifier = composioPlugin.create({ + composioApiKey: "key", + _clientOverride: mockClient, + }); + const result = await notifier.post!("test"); + expect(result).toBeNull(); + }); + }); + + describe("error handling", () => { + it("unsuccessful result throws descriptive error", async () => { + const failClient = { + executeAction: vi.fn().mockResolvedValue({ + successful: false, + error: "Channel not found", + }), + }; + + const notifier = composioPlugin.create({ + composioApiKey: "key", + _clientOverride: failClient, + }); + await expect(notifier.notify(makeEvent())).rejects.toThrow("Channel not found"); + }); + }); +}); diff --git a/packages/integration-tests/src/notifier-desktop.integration.test.ts b/packages/integration-tests/src/notifier-desktop.integration.test.ts new file mode 100644 index 000000000..0c7d34b6f --- /dev/null +++ b/packages/integration-tests/src/notifier-desktop.integration.test.ts @@ -0,0 +1,210 @@ +/** + * Integration tests for notifier-desktop. + * + * Mocks ONLY the I/O boundary: node:child_process and node:os. + * Everything else runs for real: config parsing, escaping chains, formatting. + */ +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import type { NotifyAction } from "@agent-orchestrator/core"; +import { makeEvent } from "./helpers/event-factory.js"; + +vi.mock("node:child_process", () => ({ + execFile: vi.fn(), +})); + +vi.mock("node:os", () => ({ + platform: vi.fn(() => "darwin"), +})); + +import { execFile } from "node:child_process"; +import { platform } from "node:os"; + +const mockExecFile = execFile as unknown as Mock; +const mockPlatform = platform as unknown as Mock; + +// Import the full plugin module — config parsing, escaping, formatting all run for real +import desktopPlugin from "@agent-orchestrator/plugin-notifier-desktop"; + +describe("notifier-desktop integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPlatform.mockReturnValue("darwin"); + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], cb: (err: Error | null) => void) => { + cb(null); + }, + ); + }); + + describe("config -> behavior flow", () => { + it("sound=false config + urgent event -> no sound clause in osascript", async () => { + const notifier = desktopPlugin.create({ sound: false }); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).not.toContain("sound name"); + expect(script).toContain("URGENT"); + }); + + it("default config + urgent event -> sound clause present", async () => { + const notifier = desktopPlugin.create(); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain('sound name "default"'); + }); + + it("default config + info event -> no sound clause", async () => { + const notifier = desktopPlugin.create(); + await notifier.notify(makeEvent({ priority: "info" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).not.toContain("sound name"); + }); + + it("sound=true config behaves same as default", async () => { + const notifier = desktopPlugin.create({ sound: true }); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain('sound name "default"'); + }); + }); + + describe("escaping chain integrity", () => { + it("handles double quotes, backslashes, and single quotes simultaneously", async () => { + const notifier = desktopPlugin.create(); + const nastySession = `test"with\\back'slash`; + const nastyMessage = `msg "has" all\\the'chars`; + + await notifier.notify( + makeEvent({ sessionId: nastySession, message: nastyMessage }), + ); + + const script = mockExecFile.mock.calls[0][1][1] as string; + + // AppleScript escaping: " -> \" and \ -> \\ + expect(script).toContain('test\\"with\\\\back'); + expect(script).toContain('msg \\"has\\" all\\\\the'); + + // The script should be valid AppleScript structure + expect(script).toContain("display notification"); + expect(script).toContain("with title"); + }); + + it("escapes newlines in message text (renders literally in notification)", async () => { + const notifier = desktopPlugin.create(); + await notifier.notify(makeEvent({ message: "line1\nline2" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + // newlines in AppleScript strings are ok, they render as literal newlines + expect(script).toContain("line1\nline2"); + }); + }); + + describe("platform routing", () => { + it("darwin -> osascript with -e flag", async () => { + mockPlatform.mockReturnValue("darwin"); + const notifier = desktopPlugin.create(); + await notifier.notify(makeEvent()); + + expect(mockExecFile.mock.calls[0][0]).toBe("osascript"); + expect(mockExecFile.mock.calls[0][1][0]).toBe("-e"); + }); + + it("linux -> notify-send with title and message as separate args", async () => { + mockPlatform.mockReturnValue("linux"); + const notifier = desktopPlugin.create(); + await notifier.notify(makeEvent({ sessionId: "backend-1", message: "Test msg" })); + + expect(mockExecFile.mock.calls[0][0]).toBe("notify-send"); + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain("Agent Orchestrator [backend-1]"); + expect(args).toContain("Test msg"); + }); + + it("linux + urgent -> --urgency=critical before title", async () => { + mockPlatform.mockReturnValue("linux"); + const notifier = desktopPlugin.create(); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args[0]).toBe("--urgency=critical"); + }); + + it("linux + info -> no --urgency flag", async () => { + mockPlatform.mockReturnValue("linux"); + const notifier = desktopPlugin.create(); + await notifier.notify(makeEvent({ priority: "info" })); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).not.toContain("--urgency=critical"); + }); + + it("win32 -> no execFile call, warns", async () => { + mockPlatform.mockReturnValue("win32"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const notifier = desktopPlugin.create(); + await notifier.notify(makeEvent()); + + expect(mockExecFile).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("win32")); + warnSpy.mockRestore(); + }); + }); + + describe("notifyWithActions full pipeline", () => { + it("formats action labels into notification body text", async () => { + const notifier = desktopPlugin.create(); + const actions: NotifyAction[] = [ + { label: "Merge PR", url: "https://github.com/pr/1" }, + { label: "Kill", callbackEndpoint: "/api/kill" }, + ]; + + await notifier.notifyWithActions!(makeEvent({ priority: "urgent" }), actions); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain("Merge PR"); + expect(script).toContain("Kill"); + expect(script).toContain('sound name "default"'); + }); + + it("action labels with special chars are escaped in AppleScript", async () => { + const notifier = desktopPlugin.create(); + const actions: NotifyAction[] = [ + { label: 'Fix "bug"', url: "https://example.com" }, + ]; + + await notifier.notifyWithActions!(makeEvent(), actions); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain('Fix \\"bug\\"'); + }); + }); + + describe("error propagation", () => { + it("rejects when execFile fails on darwin", async () => { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], cb: (err: Error | null) => void) => { + cb(new Error("osascript: command not found")); + }, + ); + + const notifier = desktopPlugin.create(); + await expect(notifier.notify(makeEvent())).rejects.toThrow("osascript: command not found"); + }); + + it("rejects when execFile fails on linux", async () => { + mockPlatform.mockReturnValue("linux"); + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], cb: (err: Error | null) => void) => { + cb(new Error("notify-send: not found")); + }, + ); + + const notifier = desktopPlugin.create(); + await expect(notifier.notify(makeEvent())).rejects.toThrow("notify-send: not found"); + }); + }); +}); diff --git a/packages/integration-tests/src/notifier-slack.integration.test.ts b/packages/integration-tests/src/notifier-slack.integration.test.ts new file mode 100644 index 000000000..d63faa5cf --- /dev/null +++ b/packages/integration-tests/src/notifier-slack.integration.test.ts @@ -0,0 +1,295 @@ +/** + * Integration tests for notifier-slack. + * + * Mocks ONLY the I/O boundary: global fetch. + * Everything else runs for real: config parsing, Block Kit construction, channel routing. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { NotifyAction, EventPriority } from "@agent-orchestrator/core"; +import slackPlugin from "@agent-orchestrator/plugin-notifier-slack"; +import { makeEvent } from "./helpers/event-factory.js"; + +function mockFetchOk() { + return vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve("ok"), + }); +} + +describe("notifier-slack integration", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("config -> Block Kit structure", () => { + it("full event with prUrl + ciStatus produces complete blocks array", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = slackPlugin.create({ + webhookUrl: "https://hooks.slack.com/test", + channel: "#deploys", + username: "TestBot", + }); + + await notifier.notify( + makeEvent({ + priority: "urgent", + sessionId: "backend-3", + projectId: "integrator", + message: "CI is failing on backend-3", + data: { + prUrl: "https://github.com/org/repo/pull/42", + ciStatus: "failing", + }, + }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + + // Verify full structure + expect(body.username).toBe("TestBot"); + expect(body.channel).toBe("#deploys"); + + // Block 0: header + expect(body.blocks[0].type).toBe("header"); + expect(body.blocks[0].text.type).toBe("plain_text"); + expect(body.blocks[0].text.text).toContain(":rotating_light:"); + expect(body.blocks[0].text.text).toContain("session.spawned"); + expect(body.blocks[0].text.text).toContain("backend-3"); + expect(body.blocks[0].text.emoji).toBe(true); + + // Block 1: message section + expect(body.blocks[1].type).toBe("section"); + expect(body.blocks[1].text.type).toBe("mrkdwn"); + expect(body.blocks[1].text.text).toBe("CI is failing on backend-3"); + + // Block 2: context with project/priority/time + expect(body.blocks[2].type).toBe("context"); + expect(body.blocks[2].elements[0].text).toContain("*Project:* integrator"); + expect(body.blocks[2].elements[0].text).toContain("*Priority:* urgent"); + + // Block 3: PR link + const prBlock = body.blocks.find( + (b: Record) => + b.type === "section" && + typeof (b as { text?: { text?: string } }).text?.text === "string" && + (b as { text: { text: string } }).text.text.includes("View Pull Request"), + ); + expect(prBlock).toBeDefined(); + expect(prBlock.text.text).toContain("https://github.com/org/repo/pull/42"); + + // CI status block + const ciBlock = body.blocks.find( + (b: Record) => + b.type === "context" && + Array.isArray((b as { elements?: unknown[] }).elements) && + typeof (b as { elements: Array<{ text?: string }> }).elements[0]?.text === "string" && + (b as { elements: Array<{ text: string }> }).elements[0].text.includes("CI:"), + ); + expect(ciBlock).toBeDefined(); + expect(ciBlock.elements[0].text).toContain(":x:"); + expect(ciBlock.elements[0].text).toContain("failing"); + + // Last block: divider + expect(body.blocks[body.blocks.length - 1].type).toBe("divider"); + }); + + it("passing CI uses check mark emoji", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ data: { ciStatus: "passing" } })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const ciBlock = body.blocks.find( + (b: Record) => + b.type === "context" && + Array.isArray((b as { elements?: unknown[] }).elements) && + typeof (b as { elements: Array<{ text?: string }> }).elements[0]?.text === "string" && + (b as { elements: Array<{ text: string }> }).elements[0].text.includes("CI:"), + ); + expect(ciBlock.elements[0].text).toContain(":white_check_mark:"); + }); + + it("event without prUrl or ciStatus omits those blocks", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ data: {} })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + // Should have: header, section, context, divider = 4 blocks + expect(body.blocks).toHaveLength(4); + }); + }); + + describe("priority emoji routing", () => { + it.each([ + ["urgent", ":rotating_light:"], + ["action", ":point_right:"], + ["warning", ":warning:"], + ["info", ":information_source:"], + ] as Array<[EventPriority, string]>)( + "priority %s -> emoji %s", + async (priority, expectedEmoji) => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ priority })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.blocks[0].text.text).toContain(expectedEmoji); + }, + ); + }); + + describe("notifyWithActions full pipeline", () => { + it("renders URL actions as buttons and callback actions with action_id", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" }); + const actions: NotifyAction[] = [ + { label: "Merge PR", url: "https://github.com/merge" }, + { label: "Kill Session", callbackEndpoint: "/api/kill/app-1" }, + ]; + + await notifier.notifyWithActions!(makeEvent(), actions); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + expect(actionsBlock).toBeDefined(); + expect(actionsBlock.elements).toHaveLength(2); + + // URL button + expect(actionsBlock.elements[0].type).toBe("button"); + expect(actionsBlock.elements[0].text.text).toBe("Merge PR"); + expect(actionsBlock.elements[0].url).toBe("https://github.com/merge"); + + // Callback button + expect(actionsBlock.elements[1].type).toBe("button"); + expect(actionsBlock.elements[1].action_id).toBe("ao_kill_session_1"); + expect(actionsBlock.elements[1].value).toBe("/api/kill/app-1"); + }); + + it("actions without url or callback are filtered out", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" }); + const actions: NotifyAction[] = [ + { label: "No Link" }, + { label: "Has Link", url: "https://example.com" }, + ]; + + await notifier.notifyWithActions!(makeEvent(), actions); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + expect(actionsBlock.elements).toHaveLength(1); + expect(actionsBlock.elements[0].text.text).toBe("Has Link"); + }); + + it("empty valid actions list produces no actions block", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notifyWithActions!(makeEvent(), [{ label: "No Link" }]); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + expect(actionsBlock).toBeUndefined(); + }); + }); + + describe("post full pipeline", () => { + it("sends text with username and channel", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = slackPlugin.create({ + webhookUrl: "https://hooks.slack.com/test", + channel: "#default-channel", + username: "CustomBot", + }); + await notifier.post!("Summary message"); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.text).toBe("Summary message"); + expect(body.username).toBe("CustomBot"); + expect(body.channel).toBe("#default-channel"); + }); + + it("context channel overrides default channel", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = slackPlugin.create({ + webhookUrl: "https://hooks.slack.com/test", + channel: "#default", + }); + await notifier.post!("test", { channel: "#specific" }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.channel).toBe("#specific"); + }); + }); + + describe("no-op behavior", () => { + it("all methods are no-ops when no webhookUrl", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const notifier = slackPlugin.create(); + await notifier.notify(makeEvent()); + await notifier.notifyWithActions!(makeEvent(), [{ label: "Test", url: "https://x.com" }]); + const result = await notifier.post!("test"); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); + + describe("error propagation", () => { + it("non-ok response throws with status and body", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve("Internal Server Error"), + }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" }); + await expect(notifier.notify(makeEvent())).rejects.toThrow( + "Slack webhook failed (500): Internal Server Error", + ); + }); + }); + + describe("timestamp formatting", () => { + it("includes Unix timestamp in Slack date format", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const ts = new Date("2025-06-15T12:00:00Z"); + const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ timestamp: ts })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const contextText = body.blocks[2].elements[0].text; + const unixTs = Math.floor(ts.getTime() / 1000); + expect(contextText).toContain(` { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + describe("payload serialization", () => { + it("notify serializes event with ISO timestamp and preserves data", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = webhookPlugin.create({ url: "https://example.com/hook" }); + const event = makeEvent({ + type: "ci.failing", + priority: "action", + message: "CI check failed", + data: { checkName: "lint" }, + }); + await notifier.notify(event); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.type).toBe("notification"); + expect(body.event.id).toBe("evt-test-1"); + expect(body.event.type).toBe("ci.failing"); + expect(body.event.priority).toBe("action"); + expect(body.event.sessionId).toBe("app-1"); + expect(body.event.projectId).toBe("my-project"); + expect(body.event.timestamp).toBe("2025-06-15T12:00:00.000Z"); + expect(body.event.message).toBe("CI check failed"); + expect(body.event.data).toEqual({ checkName: "lint" }); + }); + + it("notifyWithActions includes actions array in payload", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const actions: NotifyAction[] = [ + { label: "Merge", url: "https://github.com/merge" }, + { label: "Kill", callbackEndpoint: "/api/kill" }, + ]; + + const notifier = webhookPlugin.create({ url: "https://example.com/hook" }); + await notifier.notifyWithActions!(makeEvent(), actions); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.type).toBe("notification_with_actions"); + expect(body.actions).toEqual([ + { label: "Merge", url: "https://github.com/merge", callbackEndpoint: undefined }, + { label: "Kill", url: undefined, callbackEndpoint: "/api/kill" }, + ]); + }); + + it("post includes message and context in payload", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = webhookPlugin.create({ url: "https://example.com/hook" }); + await notifier.post!("All sessions done", { projectId: "my-project", channel: "#ops" }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.type).toBe("message"); + expect(body.message).toBe("All sessions done"); + expect(body.context).toEqual({ projectId: "my-project", channel: "#ops" }); + }); + }); + + describe("custom headers", () => { + it("merges custom headers with Content-Type", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = webhookPlugin.create({ + url: "https://example.com/hook", + headers: { Authorization: "Bearer tok", "X-Custom": "val" }, + }); + await notifier.notify(makeEvent()); + + const headers = fetchMock.mock.calls[0][1].headers; + expect(headers["Content-Type"]).toBe("application/json"); + expect(headers["Authorization"]).toBe("Bearer tok"); + expect(headers["X-Custom"]).toBe("val"); + }); + + it("ignores non-string header values", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = webhookPlugin.create({ + url: "https://example.com/hook", + headers: { valid: "yes", invalid: 42 }, + }); + await notifier.notify(makeEvent()); + + const headers = fetchMock.mock.calls[0][1].headers; + expect(headers["valid"]).toBe("yes"); + expect(headers["invalid"]).toBeUndefined(); + }); + }); + + describe("retry timing with fake timers", () => { + it("retries=2, retryDelayMs=100: 503, 503, 200 -> 3 fetch calls with exponential backoff", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("unavailable") }) + .mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("unavailable") }) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = webhookPlugin.create({ + url: "https://example.com/hook", + retries: 2, + retryDelayMs: 100, + }); + + const promise = notifier.notify(makeEvent()); + + // First attempt happens immediately + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // First retry after 100ms (100 * 2^0) + await vi.advanceTimersByTimeAsync(100); + expect(fetchMock).toHaveBeenCalledTimes(2); + + // Second retry after 200ms (100 * 2^1) + await vi.advanceTimersByTimeAsync(200); + expect(fetchMock).toHaveBeenCalledTimes(3); + + await promise; + }); + + it("retries=1, retryDelayMs=500: delay * 2^0 = 500ms for first retry", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("error") }) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = webhookPlugin.create({ + url: "https://example.com/hook", + retries: 1, + retryDelayMs: 500, + }); + + const promise = notifier.notify(makeEvent()); + + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // At 499ms: not yet retried + await vi.advanceTimersByTimeAsync(499); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // At 500ms: retry fires + await vi.advanceTimersByTimeAsync(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + + await promise; + }); + }); + + describe("non-retryable errors", () => { + it.each([400, 401, 403, 404])( + "HTTP %d -> single fetch call, immediate rejection", + async (status) => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status, + text: () => Promise.resolve("client error"), + }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = webhookPlugin.create({ + url: "https://example.com/hook", + retries: 3, + retryDelayMs: 1, + }); + + await expect(notifier.notify(makeEvent())).rejects.toThrow( + `Webhook POST failed (${status})`, + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }, + ); + }); + + describe("retryable errors", () => { + it("429 is retried", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 429, text: () => Promise.resolve("rate limited") }) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = webhookPlugin.create({ + url: "https://example.com/hook", + retries: 1, + retryDelayMs: 1, + }); + await notifier.notify(makeEvent()); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("network errors (ECONNREFUSED) are retried", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = webhookPlugin.create({ + url: "https://example.com/hook", + retries: 1, + retryDelayMs: 1, + }); + await notifier.notify(makeEvent()); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("throws after all retries exhausted", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve("server error"), + }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = webhookPlugin.create({ + url: "https://example.com/hook", + retries: 2, + retryDelayMs: 1, + }); + + await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (500)"); + expect(fetchMock).toHaveBeenCalledTimes(3); // 1 initial + 2 retries + }); + }); + + describe("config validation", () => { + it("throws on non-http(s) URL", () => { + expect(() => webhookPlugin.create({ url: "ftp://example.com" })).toThrow("must be http(s)"); + }); + + it("accepts http URL", () => { + expect(() => webhookPlugin.create({ url: "http://localhost:3000/hook" })).not.toThrow(); + }); + + it("retries defaults to 2 when not specified", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve("error"), + }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = webhookPlugin.create({ url: "https://example.com/hook", retryDelayMs: 1 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(3); // 1 + 2 default retries + }); + + it("retries=0 means no retries", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve("error"), + }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = webhookPlugin.create({ + url: "https://example.com/hook", + retries: 0, + retryDelayMs: 1, + }); + await expect(notifier.notify(makeEvent())).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe("no-op behavior", () => { + it("all methods no-op when no URL configured", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const notifier = webhookPlugin.create(); + await notifier.notify(makeEvent()); + await notifier.notifyWithActions!(makeEvent(), [{ label: "Test" }]); + const result = await notifier.post!("msg"); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/integration-tests/src/terminal-iterm2.integration.test.ts b/packages/integration-tests/src/terminal-iterm2.integration.test.ts new file mode 100644 index 000000000..57fad3d42 --- /dev/null +++ b/packages/integration-tests/src/terminal-iterm2.integration.test.ts @@ -0,0 +1,220 @@ +/** + * Integration tests for terminal-iterm2. + * + * Mocks ONLY the I/O boundary: node:child_process and node:os. + * Everything else runs for real: AppleScript generation, escaping chains, tab reuse logic. + */ +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +vi.mock("node:child_process", () => ({ + execFile: vi.fn(), +})); + +vi.mock("node:os", () => ({ + platform: vi.fn(() => "darwin"), +})); + +import { execFile } from "node:child_process"; +import { platform } from "node:os"; + +const mockExecFile = execFile as unknown as Mock; +const mockPlatform = platform as unknown as Mock; + +import iterm2Plugin from "@agent-orchestrator/plugin-terminal-iterm2"; +import { makeSession } from "./helpers/event-factory.js"; + +function simulateOsascript(stdout: string) { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { + cb(null, stdout); + }, + ); +} + +function simulateOsascriptSequence(results: string[]) { + let callIndex = 0; + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { + const result = results[callIndex] ?? "NOT_FOUND"; + callIndex++; + cb(null, result); + }, + ); +} + +describe("terminal-iterm2 integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockPlatform.mockReturnValue("darwin"); + }); + + describe("escaping chain integrity", () => { + it("handles double quotes, backslashes, and single quotes simultaneously in session name", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = iterm2Plugin.create(); + + const nastyName = `it's-a-"test"\\session`; + await terminal.openSession(makeSession({ id: nastyName })); + + // Call 0: findAndSelectExistingTab + const findScript = mockExecFile.mock.calls[0][1][1] as string; + // AppleScript escaping: " -> \" and \ -> \\ + expect(findScript).toContain('it\'s-a-\\"test\\"\\\\session'); + + // Call 1: openNewTab + const openScript = mockExecFile.mock.calls[1][1][1] as string; + // AppleScript escaping in set name + expect(openScript).toContain('set name to "it\'s-a-\\"test\\"\\\\session"'); + // Shell escaping then AppleScript escaping in tmux command: + // Shell: 'it'\''s-a-"test"\session' -> AppleScript doubles backslashes + expect(openScript).toContain("tmux attach -t 'it'\\\\''s-a-\\\"test\\\"\\\\session'"); + }); + }); + + describe("tab reuse logic", () => { + it("FOUND response -> no openNewTab call (only 1 execFile call)", async () => { + simulateOsascript("FOUND\n"); + const terminal = iterm2Plugin.create(); + await terminal.openSession(makeSession()); + + expect(mockExecFile).toHaveBeenCalledTimes(1); + // Should be the findAndSelectExistingTab call only + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain("select aWindow"); + expect(script).toContain("select aTab"); + }); + + it("NOT_FOUND response -> openNewTab call (2 execFile calls)", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = iterm2Plugin.create(); + await terminal.openSession(makeSession()); + + expect(mockExecFile).toHaveBeenCalledTimes(2); + const openScript = mockExecFile.mock.calls[1][1][1] as string; + expect(openScript).toContain("create tab with default profile"); + }); + + it("runtimeHandle.id takes precedence over session.id", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = iterm2Plugin.create(); + await terminal.openSession( + makeSession({ + id: "app-1", + runtimeHandle: { id: "tmux-session-42", runtimeName: "tmux", data: {} }, + }), + ); + + const findScript = mockExecFile.mock.calls[0][1][1] as string; + expect(findScript).toContain("tmux-session-42"); + expect(findScript).not.toContain('"app-1"'); + }); + }); + + describe("isSessionOpen no side effects", () => { + it("script contains no select commands", async () => { + simulateOsascript("FOUND\n"); + const terminal = iterm2Plugin.create(); + await terminal.isSessionOpen!(makeSession({ id: "check-only" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).not.toContain("select aWindow"); + expect(script).not.toContain("select aTab"); + // It should still check name + expect(script).toContain("name of aSession"); + expect(script).toContain('"check-only"'); + }); + + it("returns true when FOUND", async () => { + simulateOsascript("FOUND\n"); + const terminal = iterm2Plugin.create(); + const result = await terminal.isSessionOpen!(makeSession()); + expect(result).toBe(true); + }); + + it("returns false when NOT_FOUND", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = iterm2Plugin.create(); + const result = await terminal.isSessionOpen!(makeSession()); + expect(result).toBe(false); + }); + + it("returns false when osascript throws", async () => { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { + cb(new Error("script error"), ""); + }, + ); + const terminal = iterm2Plugin.create(); + const result = await terminal.isSessionOpen!(makeSession()); + expect(result).toBe(false); + }); + }); + + describe("platform guard", () => { + it("openSession is no-op on non-macOS and warns", async () => { + mockPlatform.mockReturnValue("linux"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const terminal = iterm2Plugin.create(); + await terminal.openSession(makeSession()); + + expect(mockExecFile).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("only available on macOS")); + warnSpy.mockRestore(); + }); + + it("openAll is no-op on non-macOS", async () => { + mockPlatform.mockReturnValue("win32"); + const terminal = iterm2Plugin.create(); + await terminal.openAll([makeSession()]); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it("isSessionOpen returns false on non-macOS", async () => { + mockPlatform.mockReturnValue("linux"); + const terminal = iterm2Plugin.create(); + const result = await terminal.isSessionOpen!(makeSession()); + expect(result).toBe(false); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + }); + + describe("openAll full pipeline", () => { + it("opens tabs for each session with delay between operations", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = iterm2Plugin.create(); + const sessions = [ + makeSession({ id: "s1" }), + makeSession({ id: "s2" }), + ]; + + const promise = terminal.openAll(sessions); + + // Process first session + await vi.advanceTimersByTimeAsync(300); + // Process second session + await vi.advanceTimersByTimeAsync(300); + await promise; + + // 2 sessions * 2 calls each (find + open) = 4 + expect(mockExecFile).toHaveBeenCalledTimes(4); + }); + + it("skips existing sessions and opens only new ones", async () => { + simulateOsascriptSequence(["FOUND\n", "NOT_FOUND\n", ""]); + const terminal = iterm2Plugin.create(); + const sessions = [ + makeSession({ id: "existing" }), + makeSession({ id: "new-one" }), + ]; + + const promise = terminal.openAll(sessions); + await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(300); + await promise; + + // existing: 1 call (find=FOUND), new-one: 2 calls (find + open) + expect(mockExecFile).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/packages/integration-tests/src/terminal-web.integration.test.ts b/packages/integration-tests/src/terminal-web.integration.test.ts new file mode 100644 index 000000000..17a50cfc5 --- /dev/null +++ b/packages/integration-tests/src/terminal-web.integration.test.ts @@ -0,0 +1,105 @@ +/** + * Integration tests for terminal-web. + * + * No I/O mocking needed — this plugin has no external I/O. + * Tests verify state tracking, URL construction, and log output. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import webPlugin from "@agent-orchestrator/plugin-terminal-web"; +import { makeSession } from "./helpers/event-factory.js"; + +describe("terminal-web integration", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe("state tracking", () => { + it("isSessionOpen returns false before openSession, true after", async () => { + const terminal = webPlugin.create(); + const session = makeSession({ id: "track-1" }); + + expect(await terminal.isSessionOpen!(session)).toBe(false); + await terminal.openSession(session); + expect(await terminal.isSessionOpen!(session)).toBe(true); + }); + + it("openAll marks all sessions as open", async () => { + const terminal = webPlugin.create(); + const sessions = [ + makeSession({ id: "a" }), + makeSession({ id: "b" }), + makeSession({ id: "c" }), + ]; + + await terminal.openAll(sessions); + + for (const s of sessions) { + expect(await terminal.isSessionOpen!(s)).toBe(true); + } + }); + + it("tracks sessions independently across openSession and openAll", async () => { + const terminal = webPlugin.create(); + const single = makeSession({ id: "single" }); + const batchA = makeSession({ id: "batch-a" }); + const batchB = makeSession({ id: "batch-b" }); + const never = makeSession({ id: "never" }); + + await terminal.openSession(single); + await terminal.openAll([batchA, batchB]); + + expect(await terminal.isSessionOpen!(single)).toBe(true); + expect(await terminal.isSessionOpen!(batchA)).toBe(true); + expect(await terminal.isSessionOpen!(batchB)).toBe(true); + expect(await terminal.isSessionOpen!(never)).toBe(false); + }); + }); + + describe("URL construction", () => { + it("default dashboard URL uses port 3000", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const terminal = webPlugin.create(); + await terminal.openSession(makeSession({ id: "url-test" })); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("http://localhost:3000/sessions/url-test/terminal"), + ); + }); + + it("custom dashboard URL is used in log output", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const terminal = webPlugin.create({ dashboardUrl: "https://my-dash.io:8080" }); + await terminal.openSession(makeSession({ id: "custom-url" })); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("https://my-dash.io:8080/sessions/custom-url/terminal"), + ); + }); + + it("openAll logs total count and dashboard URL", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const terminal = webPlugin.create({ dashboardUrl: "https://dashboard.io" }); + await terminal.openAll([ + makeSession({ id: "x" }), + makeSession({ id: "y" }), + ]); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("2 sessions"), + ); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("https://dashboard.io/sessions"), + ); + }); + }); + + describe("empty sessions", () => { + it("openAll handles empty array", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const terminal = webPlugin.create(); + await terminal.openAll([]); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("0 sessions")); + }); + }); +}); diff --git a/packages/integration-tests/vitest.config.ts b/packages/integration-tests/vitest.config.ts index 38fc0d1d6..e705c97c6 100644 --- a/packages/integration-tests/vitest.config.ts +++ b/packages/integration-tests/vitest.config.ts @@ -5,11 +5,6 @@ export default defineConfig({ testTimeout: 120_000, hookTimeout: 60_000, pool: "forks", - poolOptions: { - forks: { - singleFork: true, - }, - }, include: ["src/**/*.integration.test.ts"], }, }); diff --git a/packages/plugins/notifier-composio/package.json b/packages/plugins/notifier-composio/package.json new file mode 100644 index 000000000..3c8dc9db0 --- /dev/null +++ b/packages/plugins/notifier-composio/package.json @@ -0,0 +1,36 @@ +{ + "name": "@agent-orchestrator/plugin-notifier-composio", + "version": "0.1.0", + "description": "Notifier plugin: Composio unified notifications (Slack, Discord, email)", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@agent-orchestrator/core": "workspace:*" + }, + "peerDependencies": { + "composio-core": ">=0.5.0" + }, + "peerDependenciesMeta": { + "composio-core": { + "optional": true + } + }, + "devDependencies": { + "@types/node": "^25.2.3", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/notifier-composio/src/index.test.ts b/packages/plugins/notifier-composio/src/index.test.ts new file mode 100644 index 000000000..8b63ef21b --- /dev/null +++ b/packages/plugins/notifier-composio/src/index.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { OrchestratorEvent, NotifyAction } from "@agent-orchestrator/core"; +import { manifest, create } from "./index.js"; + +function makeEvent(overrides: Partial = {}): OrchestratorEvent { + return { + id: "evt-1", + type: "session.spawned", + priority: "info", + sessionId: "app-1", + projectId: "my-project", + timestamp: new Date("2025-06-15T12:00:00Z"), + message: "Session app-1 spawned successfully", + data: {}, + ...overrides, + }; +} + +const mockExecuteAction = vi.fn().mockResolvedValue({ successful: true }); + +vi.mock("composio-core", () => { + // Must use a regular function (not arrow) to be callable with `new` + function MockComposio() { + return { executeAction: mockExecuteAction }; + } + return { Composio: MockComposio }; +}); + +describe("notifier-composio", () => { + const originalEnv = process.env.COMPOSIO_API_KEY; + + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.COMPOSIO_API_KEY; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.COMPOSIO_API_KEY = originalEnv; + } else { + delete process.env.COMPOSIO_API_KEY; + } + }); + + describe("manifest", () => { + it("has correct metadata", () => { + expect(manifest.name).toBe("composio"); + expect(manifest.slot).toBe("notifier"); + }); + + it("has a version", () => { + expect(manifest.version).toBe("0.1.0"); + }); + }); + + describe("create — config parsing", () => { + it("reads apiKey from config", () => { + const notifier = create({ composioApiKey: "test-key" }); + expect(notifier.name).toBe("composio"); + }); + + it("reads apiKey from COMPOSIO_API_KEY env var", () => { + process.env.COMPOSIO_API_KEY = "env-key"; + const notifier = create(); + expect(notifier.name).toBe("composio"); + }); + + it("throws on invalid defaultApp", () => { + expect(() => create({ composioApiKey: "k", defaultApp: "telegram" })).toThrow( + 'Invalid defaultApp: "telegram"', + ); + }); + + it("accepts slack as defaultApp", () => { + expect(() => create({ composioApiKey: "k", defaultApp: "slack" })).not.toThrow(); + }); + + it("accepts discord as defaultApp", () => { + expect(() => create({ composioApiKey: "k", defaultApp: "discord" })).not.toThrow(); + }); + + it("accepts gmail as defaultApp with emailTo", () => { + expect(() => create({ composioApiKey: "k", defaultApp: "gmail", emailTo: "a@b.com" })).not.toThrow(); + }); + + it("throws when gmail is defaultApp without emailTo", () => { + expect(() => create({ composioApiKey: "k", defaultApp: "gmail" })).toThrow( + "emailTo is required", + ); + }); + + it("defaults to slack when defaultApp not specified", async () => { + const notifier = create({ composioApiKey: "k" }); + await notifier.notify(makeEvent()); + + expect(mockExecuteAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "SLACK_SEND_MESSAGE", + }), + ); + }); + }); + + describe("notify", () => { + it("calls SLACK_SEND_MESSAGE for slack app", async () => { + const notifier = create({ composioApiKey: "k", defaultApp: "slack" }); + await notifier.notify(makeEvent()); + + expect(mockExecuteAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "SLACK_SEND_MESSAGE", + }), + ); + }); + + it("calls DISCORD_SEND_MESSAGE for discord app", async () => { + const notifier = create({ composioApiKey: "k", defaultApp: "discord" }); + await notifier.notify(makeEvent()); + + expect(mockExecuteAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "DISCORD_SEND_MESSAGE", + }), + ); + }); + + it("calls GMAIL_SEND_EMAIL for gmail app", async () => { + const notifier = create({ composioApiKey: "k", defaultApp: "gmail", emailTo: "test@test.com" }); + await notifier.notify(makeEvent()); + + expect(mockExecuteAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "GMAIL_SEND_EMAIL", + }), + ); + }); + + it("routes to channelId when set", async () => { + const notifier = create({ composioApiKey: "k", channelId: "C123" }); + await notifier.notify(makeEvent()); + + const callArgs = mockExecuteAction.mock.calls[0][0]; + expect(callArgs.params.channel).toBe("C123"); + }); + + it("routes to channelName when channelId not set", async () => { + const notifier = create({ composioApiKey: "k", channelName: "#general" }); + await notifier.notify(makeEvent()); + + const callArgs = mockExecuteAction.mock.calls[0][0]; + expect(callArgs.params.channel).toBe("#general"); + }); + + it("includes priority emoji in text", async () => { + const notifier = create({ composioApiKey: "k" }); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const callArgs = mockExecuteAction.mock.calls[0][0]; + expect(callArgs.params.text).toContain("\u{1F6A8}"); + }); + + it("includes prUrl when present as string", async () => { + const notifier = create({ composioApiKey: "k" }); + await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/pull/1" } })); + + const callArgs = mockExecuteAction.mock.calls[0][0]; + expect(callArgs.params.text).toContain("https://github.com/pull/1"); + }); + + it("ignores prUrl when not a string", async () => { + const notifier = create({ composioApiKey: "k" }); + await notifier.notify(makeEvent({ data: { prUrl: 42 } })); + + const callArgs = mockExecuteAction.mock.calls[0][0]; + expect(callArgs.params.text).not.toContain("PR:"); + }); + }); + + describe("notifyWithActions", () => { + it("includes action labels in text", async () => { + const notifier = create({ composioApiKey: "k" }); + const actions: NotifyAction[] = [ + { label: "Merge", url: "https://github.com/merge" }, + { label: "Kill", callbackEndpoint: "/api/kill" }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const callArgs = mockExecuteAction.mock.calls[0][0]; + expect(callArgs.params.text).toContain("Merge"); + expect(callArgs.params.text).toContain("Kill"); + }); + + it("includes URL actions as links", async () => { + const notifier = create({ composioApiKey: "k" }); + const actions: NotifyAction[] = [ + { label: "View PR", url: "https://github.com/pull/42" }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const callArgs = mockExecuteAction.mock.calls[0][0]; + expect(callArgs.params.text).toContain("https://github.com/pull/42"); + }); + + it("renders callback-only actions without URL", async () => { + const notifier = create({ composioApiKey: "k" }); + const actions: NotifyAction[] = [ + { label: "Restart", callbackEndpoint: "/api/restart" }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const callArgs = mockExecuteAction.mock.calls[0][0]; + expect(callArgs.params.text).toContain("- Restart"); + }); + + it("uses correct tool slug for configured app", async () => { + const notifier = create({ composioApiKey: "k", defaultApp: "discord" }); + const actions: NotifyAction[] = [{ label: "Test", url: "https://example.com" }]; + await notifier.notifyWithActions!(makeEvent(), actions); + + expect(mockExecuteAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "DISCORD_SEND_MESSAGE", + }), + ); + }); + }); + + describe("post", () => { + it("sends text payload", async () => { + const notifier = create({ composioApiKey: "k" }); + await notifier.post!("Hello from AO"); + + const callArgs = mockExecuteAction.mock.calls[0][0]; + expect(callArgs.params.text).toBe("Hello from AO"); + }); + + it("overrides channel from context", async () => { + const notifier = create({ composioApiKey: "k", channelName: "#default" }); + await notifier.post!("test", { channel: "#override" }); + + const callArgs = mockExecuteAction.mock.calls[0][0]; + expect(callArgs.params.channel).toBe("#override"); + }); + + it("returns null", async () => { + const notifier = create({ composioApiKey: "k" }); + const result = await notifier.post!("test"); + expect(result).toBeNull(); + }); + }); + + describe("error handling", () => { + it("throws when SDK returns unsuccessful result", async () => { + mockExecuteAction.mockResolvedValueOnce({ + successful: false, + error: "channel not found", + }); + + const notifier = create({ composioApiKey: "k" }); + await expect(notifier.notify(makeEvent())).rejects.toThrow("channel not found"); + }); + + it("wraps SDK error with descriptive message", async () => { + mockExecuteAction.mockResolvedValueOnce({ + successful: false, + error: undefined, + }); + + const notifier = create({ composioApiKey: "k" }); + await expect(notifier.notify(makeEvent())).rejects.toThrow( + "unknown error", + ); + }); + }); + + describe("no-op when no apiKey", () => { + it("does nothing when no api key", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const notifier = create(); + await notifier.notify(makeEvent()); + expect(mockExecuteAction).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("No composioApiKey"), + ); + warnSpy.mockRestore(); + }); + }); +}); diff --git a/packages/plugins/notifier-composio/src/index.ts b/packages/plugins/notifier-composio/src/index.ts new file mode 100644 index 000000000..0c8b37b5d --- /dev/null +++ b/packages/plugins/notifier-composio/src/index.ts @@ -0,0 +1,276 @@ +import type { + PluginModule, + Notifier, + OrchestratorEvent, + NotifyAction, + NotifyContext, + EventPriority, +} from "@agent-orchestrator/core"; + +export const manifest = { + name: "composio", + slot: "notifier" as const, + description: "Notifier plugin: Composio unified notifications (Slack, Discord, email)", + version: "0.1.0", +}; + +const PRIORITY_EMOJI: Record = { + urgent: "\u{1F6A8}", + action: "\u{1F449}", + warning: "\u{26A0}\u{FE0F}", + info: "\u{2139}\u{FE0F}", +}; + +type ComposioApp = "slack" | "discord" | "gmail"; + +const APP_TOOL_SLUG: Record = { + slack: "SLACK_SEND_MESSAGE", + discord: "DISCORD_SEND_MESSAGE", + gmail: "GMAIL_SEND_EMAIL", +}; + +const VALID_APPS = new Set(["slack", "discord", "gmail"]); + +const GMAIL_SUBJECT = "Agent Orchestrator Notification"; + +interface ComposioToolkit { + executeAction(params: { + action: string; + params: Record; + entityId?: string; + }): Promise<{ successful: boolean; data?: unknown; error?: string }>; +} + +/** + * Lazy-load composio-core SDK. + * Returns null if the package is not installed. + * + * We use dynamic import + unknown casting because composio-core is an + * optional peer dependency — it may or may not be installed, and its + * TypeScript types may not match our internal interface exactly. + */ +async function loadComposioSDK( + apiKey: string, +): Promise { + try { + // String literal import so vitest can intercept it for mocking. + // The `as unknown as …` cast is safe because we validate the shape below. + const mod = (await import("composio-core")) as unknown as Record; + const ComposioClass = (mod.Composio ?? + (mod.default as Record | undefined)?.Composio ?? + mod.default) as (new (opts: { apiKey: string }) => unknown) | undefined; + if (typeof ComposioClass !== "function") { + throw new Error("Could not find Composio class in composio-core module"); + } + const client = new ComposioClass({ apiKey }); + return client as ComposioToolkit; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined; + if ( + message.includes("Cannot find module") || + message.includes("Cannot find package") || + message.includes("MODULE_NOT_FOUND") || + code === "ERR_MODULE_NOT_FOUND" + ) { + return null; + } + throw err; + } +} + +function formatNotifyText(event: OrchestratorEvent): string { + const emoji = PRIORITY_EMOJI[event.priority]; + const parts = [ + `${emoji} *${event.type}* — ${event.sessionId}`, + event.message, + ]; + + const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined; + if (prUrl) { + parts.push(`PR: ${prUrl}`); + } + + return parts.join("\n"); +} + +function formatActionsText(event: OrchestratorEvent, actions: NotifyAction[]): string { + const base = formatNotifyText(event); + const actionLines = actions.map((a) => { + if (a.url) return `- ${a.label}: ${a.url}`; + return `- ${a.label}`; + }); + + return `${base}\n\nActions:\n${actionLines.join("\n")}`; +} + +function buildToolArgs( + app: ComposioApp, + text: string, + channelId?: string, + channelName?: string, + emailTo?: string, +): Record { + if (app === "slack") { + const args: Record = { text }; + if (channelId) args.channel = channelId; + else if (channelName) args.channel = channelName; + return args; + } + + if (app === "discord") { + const args: Record = { content: text }; + // Discord requires numeric channel IDs — channelName is not supported + if (channelId) args.channel_id = channelId; + else if (channelName) args.channel_id = channelName; + return args; + } + + // gmail — emailTo is required, validated at config time + return { + to: emailTo ?? "", + subject: GMAIL_SUBJECT, + body: text, + }; +} + +export function create(config?: Record): Notifier { + const apiKey = + (typeof config?.composioApiKey === "string" ? config.composioApiKey : undefined) ?? + process.env.COMPOSIO_API_KEY; + const defaultApp: ComposioApp = + typeof config?.defaultApp === "string" && VALID_APPS.has(config.defaultApp) + ? (config.defaultApp as ComposioApp) + : "slack"; + const channelName = typeof config?.channelName === "string" ? config.channelName : undefined; + const channelId = typeof config?.channelId === "string" ? config.channelId : undefined; + const emailTo = typeof config?.emailTo === "string" ? config.emailTo : undefined; + + // Internal: allows tests to inject a mock client without mocking composio-core + const clientOverride = + config?._clientOverride !== undefined && + config._clientOverride !== null && + typeof (config._clientOverride as ComposioToolkit).executeAction === "function" + ? (config._clientOverride as ComposioToolkit) + : undefined; + + if (typeof config?.defaultApp === "string" && !VALID_APPS.has(config.defaultApp)) { + throw new Error( + `[notifier-composio] Invalid defaultApp: "${config.defaultApp}". Must be one of: slack, discord, gmail`, + ); + } + + if (defaultApp === "gmail" && !emailTo) { + throw new Error( + "[notifier-composio] emailTo is required when defaultApp is \"gmail\"", + ); + } + + let client: ComposioToolkit | null | undefined = clientOverride; + let warnedNoKey = false; + let sdkMissing = false; + + async function getClient(): Promise { + // If a client override was injected, always use it + if (clientOverride) return clientOverride; + + if (!apiKey) { + if (!warnedNoKey) { + console.warn( + "[notifier-composio] No composioApiKey or COMPOSIO_API_KEY configured — notifications will be no-ops", + ); + warnedNoKey = true; + } + return null; + } + + if (sdkMissing) return null; + + if (client === undefined) { + client = await loadComposioSDK(apiKey); + if (client === null) { + sdkMissing = true; + // eslint-disable-next-line no-console + console.warn( + "[notifier-composio] composio-core package is not installed — notifications will be no-ops. Run: npm install composio-core", + ); + return null; + } + } + + return client; + } + + async function executeWithTimeout( + composio: ComposioToolkit, + action: string, + params: Record, + ): Promise { + const timeoutMs = 30_000; + const timeoutSignal = AbortSignal.timeout(timeoutMs); + + const actionPromise = composio.executeAction({ action, params }); + // Prevent unhandled rejection if the timeout fires and actionPromise later rejects + actionPromise.catch(() => {}); + + const result = await Promise.race([ + actionPromise, + new Promise((_, reject) => { + timeoutSignal.addEventListener("abort", () => { + reject(new Error(`[notifier-composio] Composio API call timed out after ${timeoutMs / 1000}s`)); + }, { once: true }); + }), + ]); + + if (!result.successful) { + throw new Error( + `[notifier-composio] Composio action ${action} failed: ${result.error ?? "unknown error"}`, + ); + } + } + + return { + name: "composio", + + async notify(event: OrchestratorEvent): Promise { + const composio = await getClient(); + if (!composio) return; + + const text = formatNotifyText(event); + const toolSlug = APP_TOOL_SLUG[defaultApp]; + const args = buildToolArgs(defaultApp, text, channelId, channelName, emailTo); + + await executeWithTimeout(composio, toolSlug, args); + }, + + async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { + const composio = await getClient(); + if (!composio) return; + + const text = formatActionsText(event, actions); + const toolSlug = APP_TOOL_SLUG[defaultApp]; + const args = buildToolArgs(defaultApp, text, channelId, channelName, emailTo); + + await executeWithTimeout(composio, toolSlug, args); + }, + + async post(message: string, context?: NotifyContext): Promise { + const composio = await getClient(); + if (!composio) return null; + + const channel = context?.channel ?? channelId ?? channelName; + const toolSlug = APP_TOOL_SLUG[defaultApp]; + + const args: Record = defaultApp === "gmail" + ? { to: emailTo ?? "", subject: GMAIL_SUBJECT, body: message } + : defaultApp === "discord" + ? { content: message, ...(channel ? { channel_id: channel } : {}) } + : { text: message, ...(channel ? { channel } : {}) }; + + await executeWithTimeout(composio, toolSlug, args); + return null; + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/notifier-composio/tsconfig.json b/packages/plugins/notifier-composio/tsconfig.json new file mode 100644 index 000000000..e1b71318a --- /dev/null +++ b/packages/plugins/notifier-composio/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/plugins/notifier-desktop/package.json b/packages/plugins/notifier-desktop/package.json index 41817b2b9..e58454630 100644 --- a/packages/plugins/notifier-desktop/package.json +++ b/packages/plugins/notifier-desktop/package.json @@ -14,6 +14,7 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "vitest run", "clean": "rm -rf dist" }, "dependencies": { @@ -21,6 +22,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/plugins/notifier-desktop/src/index.test.ts b/packages/plugins/notifier-desktop/src/index.test.ts new file mode 100644 index 000000000..53bfa19a4 --- /dev/null +++ b/packages/plugins/notifier-desktop/src/index.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import type { OrchestratorEvent, NotifyAction } from "@agent-orchestrator/core"; + +// Mock node:child_process +vi.mock("node:child_process", () => ({ + execFile: vi.fn(), +})); + +// Mock node:os +vi.mock("node:os", () => ({ + platform: vi.fn(() => "darwin"), +})); + +import { execFile } from "node:child_process"; +import { platform } from "node:os"; +import { manifest, create, escapeAppleScript } from "./index.js"; + +const mockExecFile = execFile as unknown as Mock; +const mockPlatform = platform as unknown as Mock; + +function makeEvent(overrides: Partial = {}): OrchestratorEvent { + return { + id: "evt-1", + type: "session.spawned", + priority: "info", + sessionId: "app-1", + projectId: "my-project", + timestamp: new Date("2025-01-01T00:00:00Z"), + message: "Session app-1 spawned", + data: {}, + ...overrides, + }; +} + +describe("notifier-desktop", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPlatform.mockReturnValue("darwin"); + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], cb: (err: Error | null) => void) => { + cb(null); + }, + ); + }); + + describe("manifest", () => { + it("has correct metadata", () => { + expect(manifest.name).toBe("desktop"); + expect(manifest.slot).toBe("notifier"); + expect(manifest.version).toBe("0.1.0"); + }); + }); + + describe("escapeAppleScript", () => { + it("escapes double quotes", () => { + expect(escapeAppleScript('hello "world"')).toBe('hello \\"world\\"'); + }); + + it("escapes backslashes", () => { + expect(escapeAppleScript("path\\to\\file")).toBe("path\\\\to\\\\file"); + }); + + it("escapes both backslashes and quotes", () => { + expect(escapeAppleScript('say \\"hi\\"')).toBe('say \\\\\\"hi\\\\\\"'); + }); + + it("returns plain strings unchanged", () => { + expect(escapeAppleScript("hello world")).toBe("hello world"); + }); + }); + + describe("create", () => { + it("returns a notifier with name 'desktop'", () => { + const notifier = create(); + expect(notifier.name).toBe("desktop"); + }); + + it("has notify and notifyWithActions methods", () => { + const notifier = create(); + expect(typeof notifier.notify).toBe("function"); + expect(typeof notifier.notifyWithActions).toBe("function"); + }); + }); + + describe("notify", () => { + it("calls osascript on macOS", async () => { + const notifier = create(); + await notifier.notify(makeEvent()); + + expect(mockExecFile).toHaveBeenCalledOnce(); + expect(mockExecFile.mock.calls[0][0]).toBe("osascript"); + expect(mockExecFile.mock.calls[0][1][0]).toBe("-e"); + }); + + it("includes session ID in title", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ sessionId: "backend-5" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain("backend-5"); + }); + + it("includes event message in notification body", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ message: "CI is failing" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain("CI is failing"); + }); + + it("uses URGENT prefix for urgent priority", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain("URGENT"); + }); + + it("uses 'Agent Orchestrator' prefix for non-urgent priority", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ priority: "action" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain("Agent Orchestrator"); + }); + + it("includes sound for urgent notifications", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain('sound name "default"'); + }); + + it("does not include sound for info notifications", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ priority: "info" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).not.toContain("sound name"); + }); + + it("does not include sound for action notifications", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ priority: "action" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).not.toContain("sound name"); + }); + + it("does not include sound for warning notifications", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ priority: "warning" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).not.toContain("sound name"); + }); + + it("respects sound=false config even for urgent", async () => { + const notifier = create({ sound: false }); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).not.toContain("sound name"); + }); + + it("escapes special characters in title and message", async () => { + const notifier = create(); + await notifier.notify( + makeEvent({ sessionId: 'test"inject', message: 'msg with "quotes" and \\backslash' }), + ); + + const script = mockExecFile.mock.calls[0][1][1] as string; + // Should not contain unescaped quotes (other than the AppleScript string delimiters) + expect(script).toContain('test\\"inject'); + expect(script).toContain('\\"quotes\\"'); + expect(script).toContain("\\\\backslash"); + }); + }); + + describe("notify on Linux", () => { + it("calls notify-send on Linux", async () => { + mockPlatform.mockReturnValue("linux"); + const notifier = create(); + await notifier.notify(makeEvent()); + + expect(mockExecFile).toHaveBeenCalledOnce(); + expect(mockExecFile.mock.calls[0][0]).toBe("notify-send"); + }); + + it("includes --urgency=critical for urgent on Linux", async () => { + mockPlatform.mockReturnValue("linux"); + const notifier = create(); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain("--urgency=critical"); + // Options must come before title/message for notify-send + const urgencyIdx = args.indexOf("--urgency=critical"); + const titleIdx = args.findIndex((a: string) => a.includes("URGENT")); + expect(urgencyIdx).toBeLessThan(titleIdx); + }); + + it("includes --urgency=critical for urgent even when sound is disabled", async () => { + mockPlatform.mockReturnValue("linux"); + const notifier = create({ sound: false }); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain("--urgency=critical"); + }); + + it("does not include --urgency=critical for info on Linux", async () => { + mockPlatform.mockReturnValue("linux"); + const notifier = create(); + await notifier.notify(makeEvent({ priority: "info" })); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).not.toContain("--urgency=critical"); + }); + }); + + describe("notify on unsupported platform", () => { + it("resolves without error on unsupported platform", async () => { + mockPlatform.mockReturnValue("win32"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const notifier = create(); + await expect(notifier.notify(makeEvent())).resolves.toBeUndefined(); + expect(mockExecFile).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not supported on win32")); + warnSpy.mockRestore(); + }); + }); + + describe("notifyWithActions", () => { + it("includes action labels in the message", async () => { + const notifier = create(); + const actions: NotifyAction[] = [ + { label: "Merge", url: "https://github.com/pr/1" }, + { label: "Kill", callbackEndpoint: "/api/kill" }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain("Merge"); + expect(script).toContain("Kill"); + }); + + it("includes sound for urgent with actions", async () => { + const notifier = create(); + const actions: NotifyAction[] = [{ label: "Fix", url: "https://example.com" }]; + await notifier.notifyWithActions!(makeEvent({ priority: "urgent" }), actions); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain('sound name "default"'); + }); + }); + + describe("error handling", () => { + it("rejects when execFile fails", async () => { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], cb: (err: Error | null) => void) => { + cb(new Error("osascript not found")); + }, + ); + const notifier = create(); + await expect(notifier.notify(makeEvent())).rejects.toThrow("osascript not found"); + }); + }); +}); diff --git a/packages/plugins/notifier-desktop/src/index.ts b/packages/plugins/notifier-desktop/src/index.ts index 7c9394c48..35b76e104 100644 --- a/packages/plugins/notifier-desktop/src/index.ts +++ b/packages/plugins/notifier-desktop/src/index.ts @@ -1 +1,116 @@ -// notifier-desktop plugin — to be implemented +import { execFile } from "node:child_process"; +import { platform } from "node:os"; +import { + escapeAppleScript, + type PluginModule, + type Notifier, + type OrchestratorEvent, + type NotifyAction, + type EventPriority, +} from "@agent-orchestrator/core"; + +export const manifest = { + name: "desktop", + slot: "notifier" as const, + description: "Notifier plugin: OS desktop notifications", + version: "0.1.0", +}; + +// Re-export for backwards compatibility +export { escapeAppleScript } from "@agent-orchestrator/core"; + +/** + * Map event priority to notification urgency: + * - urgent: sound alert + * - action: normal notification + * - info/warning: silent + */ +function shouldPlaySound(priority: EventPriority, soundEnabled: boolean): boolean { + if (!soundEnabled) return false; + return priority === "urgent"; +} + +function formatTitle(event: OrchestratorEvent): string { + const prefix = event.priority === "urgent" ? "URGENT" : "Agent Orchestrator"; + return `${prefix} [${event.sessionId}]`; +} + +function formatMessage(event: OrchestratorEvent): string { + return event.message; +} + +function formatActionsMessage(event: OrchestratorEvent, actions: NotifyAction[]): string { + const actionLabels = actions.map((a) => a.label).join(" | "); + return `${event.message}\n\nActions: ${actionLabels}`; +} + +/** + * Send a desktop notification using osascript (macOS) or notify-send (Linux). + * Falls back gracefully if neither is available. + * + * Note: Desktop notifications do not support click-through URLs natively. + * On macOS, osascript's `display notification` lacks URL support. + * Consider `terminal-notifier` for click-to-open if needed in the future. + */ +function sendNotification( + title: string, + message: string, + options: { sound: boolean; isUrgent: boolean }, +): Promise { + return new Promise((resolve, reject) => { + const os = platform(); + + if (os === "darwin") { + const safeTitle = escapeAppleScript(title); + const safeMessage = escapeAppleScript(message); + const soundClause = options.sound ? ' sound name "default"' : ""; + const script = `display notification "${safeMessage}" with title "${safeTitle}"${soundClause}`; + execFile("osascript", ["-e", script], (err) => { + if (err) reject(err); + else resolve(); + }); + } else if (os === "linux") { + // Linux urgency is driven by event priority, not the macOS sound config + const args: string[] = []; + if (options.isUrgent) { + args.push("--urgency=critical"); + } + args.push(title, message); + execFile("notify-send", args, (err) => { + if (err) reject(err); + else resolve(); + }); + } else { + console.warn(`[notifier-desktop] Desktop notifications not supported on ${os}`); + resolve(); + } + }); +} + +export function create(config?: Record): Notifier { + const soundEnabled = typeof config?.sound === "boolean" ? config.sound : true; + + return { + name: "desktop", + + async notify(event: OrchestratorEvent): Promise { + const title = formatTitle(event); + const message = formatMessage(event); + const sound = shouldPlaySound(event.priority, soundEnabled); + const isUrgent = event.priority === "urgent"; + await sendNotification(title, message, { sound, isUrgent }); + }, + + async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { + // Desktop notifications cannot display interactive action buttons. + // Actions are rendered as text labels in the notification body as a fallback. + const title = formatTitle(event); + const message = formatActionsMessage(event, actions); + const sound = shouldPlaySound(event.priority, soundEnabled); + const isUrgent = event.priority === "urgent"; + await sendNotification(title, message, { sound, isUrgent }); + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/notifier-slack/package.json b/packages/plugins/notifier-slack/package.json index adeb73955..09e322be2 100644 --- a/packages/plugins/notifier-slack/package.json +++ b/packages/plugins/notifier-slack/package.json @@ -14,6 +14,7 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "vitest run", "clean": "rm -rf dist" }, "dependencies": { @@ -21,6 +22,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/plugins/notifier-slack/src/index.test.ts b/packages/plugins/notifier-slack/src/index.test.ts new file mode 100644 index 000000000..72a4a19f7 --- /dev/null +++ b/packages/plugins/notifier-slack/src/index.test.ts @@ -0,0 +1,388 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { OrchestratorEvent, NotifyAction, EventPriority } from "@agent-orchestrator/core"; +import { manifest, create } from "./index.js"; + +function makeEvent(overrides: Partial = {}): OrchestratorEvent { + return { + id: "evt-1", + type: "session.spawned", + priority: "info", + sessionId: "app-1", + projectId: "my-project", + timestamp: new Date("2025-06-15T12:00:00Z"), + message: "Session app-1 spawned successfully", + data: {}, + ...overrides, + }; +} + +function mockFetchOk() { + return vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve("ok"), + }); +} + +describe("notifier-slack", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe("manifest", () => { + it("has correct metadata", () => { + expect(manifest.name).toBe("slack"); + expect(manifest.slot).toBe("notifier"); + expect(manifest.version).toBe("0.1.0"); + }); + }); + + describe("create", () => { + it("returns a notifier with name 'slack'", () => { + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + expect(notifier.name).toBe("slack"); + }); + + it("warns when no webhookUrl configured", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + create(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No webhookUrl configured")); + }); + + it("throws on invalid URL scheme", () => { + expect(() => create({ webhookUrl: "file:///etc/passwd" })).toThrow("must be http(s)"); + }); + }); + + describe("notify", () => { + it("does nothing when no webhookUrl", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + const notifier = create(); + await notifier.notify(makeEvent()); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("POSTs to the webhook URL", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent()); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][0]).toBe("https://hooks.slack.com/test"); + expect(fetchMock.mock.calls[0][1].method).toBe("POST"); + }); + + it("sends JSON with Content-Type header", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent()); + + const opts = fetchMock.mock.calls[0][1]; + expect(opts.headers["Content-Type"]).toBe("application/json"); + }); + + it("includes username in payload", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent()); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.username).toBe("Agent Orchestrator"); + }); + + it("uses custom username when configured", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + webhookUrl: "https://hooks.slack.com/test", + username: "MyBot", + }); + await notifier.notify(makeEvent()); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.username).toBe("MyBot"); + }); + + it("includes channel when configured", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + webhookUrl: "https://hooks.slack.com/test", + channel: "#deploys", + }); + await notifier.notify(makeEvent()); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.channel).toBe("#deploys"); + }); + + it("throws on non-ok response", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve("server error"), + }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await expect(notifier.notify(makeEvent())).rejects.toThrow( + "Slack webhook failed (500): server error", + ); + }); + }); + + describe("Block Kit formatting", () => { + it("includes header block with priority emoji and session ID", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ priority: "urgent", sessionId: "backend-3" })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const header = body.blocks[0]; + expect(header.type).toBe("header"); + expect(header.text.type).toBe("plain_text"); + expect(header.text.text).toContain(":rotating_light:"); + expect(header.text.text).toContain("backend-3"); + }); + + it("uses correct emoji for each priority level", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + + const priorities: Array<[EventPriority, string]> = [ + ["urgent", ":rotating_light:"], + ["action", ":point_right:"], + ["warning", ":warning:"], + ["info", ":information_source:"], + ]; + + for (const [priority, emoji] of priorities) { + fetchMock.mockClear(); + await notifier.notify(makeEvent({ priority })); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.blocks[0].text.text).toContain(emoji); + } + }); + + it("includes section block with event message", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ message: "CI is green" })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const section = body.blocks[1]; + expect(section.type).toBe("section"); + expect(section.text.text).toBe("CI is green"); + }); + + it("includes context block with project and priority", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ projectId: "frontend", priority: "action" })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const context = body.blocks[2]; + expect(context.type).toBe("context"); + expect(context.elements[0].text).toContain("*Project:* frontend"); + expect(context.elements[0].text).toContain("*Priority:* action"); + }); + + it("includes PR link when prUrl is a string in event data", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/org/repo/pull/42" } })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const prBlock = body.blocks.find( + (b: Record) => + b.type === "section" && (b as any).text?.text?.includes("View Pull Request"), + ); + expect(prBlock).toBeDefined(); + expect(prBlock.text.text).toContain("https://github.com/org/repo/pull/42"); + }); + + it("ignores prUrl when it is not a string", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ data: { prUrl: 12345 } })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const prBlock = body.blocks.find( + (b: Record) => + b.type === "section" && (b as any).text?.text?.includes("View Pull Request"), + ); + expect(prBlock).toBeUndefined(); + }); + + it("ignores ciStatus when it is not a string", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ data: { ciStatus: { nested: true } } })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const ciBlock = body.blocks.find( + (b: Record) => + b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"), + ); + expect(ciBlock).toBeUndefined(); + }); + + it("includes CI status when ciStatus is a string", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ data: { ciStatus: "passing" } })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const ciBlock = body.blocks.find( + (b: Record) => + b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"), + ); + expect(ciBlock).toBeDefined(); + expect(ciBlock.elements[0].text).toContain(":white_check_mark:"); + }); + + it("uses :x: emoji for failing CI", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ data: { ciStatus: "failing" } })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const ciBlock = body.blocks.find( + (b: Record) => + b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"), + ); + expect(ciBlock.elements[0].text).toContain(":x:"); + }); + + it("ends with a divider block", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent()); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const lastBlock = body.blocks[body.blocks.length - 1]; + expect(lastBlock.type).toBe("divider"); + }); + }); + + describe("notifyWithActions", () => { + it("includes action buttons with URLs", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + const actions: NotifyAction[] = [ + { label: "Merge", url: "https://github.com/org/repo/pull/42/merge" }, + { label: "Open", url: "https://github.com/org/repo/pull/42" }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + expect(actionsBlock).toBeDefined(); + expect(actionsBlock.elements).toHaveLength(2); + expect(actionsBlock.elements[0].type).toBe("button"); + expect(actionsBlock.elements[0].text.text).toBe("Merge"); + }); + + it("includes callback-based action buttons", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + const actions: NotifyAction[] = [ + { label: "Kill Session", callbackEndpoint: "/api/sessions/app-1/kill" }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + expect(actionsBlock.elements[0].action_id).toBe("ao_kill_session_0"); + expect(actionsBlock.elements[0].value).toBe("/api/sessions/app-1/kill"); + }); + + it("filters out actions with no url or callback", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + const actions: NotifyAction[] = [ + { label: "No-op" }, + { label: "Merge", url: "https://example.com" }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + expect(actionsBlock.elements).toHaveLength(1); + expect(actionsBlock.elements[0].text.text).toBe("Merge"); + }); + }); + + describe("post", () => { + it("sends a text message", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + const result = await notifier.post!("Hello from AO"); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.text).toBe("Hello from AO"); + expect(result).toBeNull(); + }); + + it("uses context channel over default", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + webhookUrl: "https://hooks.slack.com/test", + channel: "#default", + }); + await notifier.post!("test", { channel: "#override" }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.channel).toBe("#override"); + }); + + it("returns null when no webhookUrl", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create(); + const result = await notifier.post!("test"); + expect(result).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/plugins/notifier-slack/src/index.ts b/packages/plugins/notifier-slack/src/index.ts index 0c6295504..7f5d14596 100644 --- a/packages/plugins/notifier-slack/src/index.ts +++ b/packages/plugins/notifier-slack/src/index.ts @@ -1 +1,188 @@ -// notifier-slack plugin — to be implemented +import { + validateUrl, + type PluginModule, + type Notifier, + type OrchestratorEvent, + type NotifyAction, + type NotifyContext, + type EventPriority, +} from "@agent-orchestrator/core"; + +export const manifest = { + name: "slack", + slot: "notifier" as const, + description: "Notifier plugin: Slack webhook notifications", + version: "0.1.0", +}; + +const PRIORITY_EMOJI: Record = { + urgent: ":rotating_light:", + action: ":point_right:", + warning: ":warning:", + info: ":information_source:", +}; + +function buildBlocks(event: OrchestratorEvent, actions?: NotifyAction[]): unknown[] { + const blocks: unknown[] = [ + { + type: "header", + text: { + type: "plain_text", + text: `${PRIORITY_EMOJI[event.priority]} ${event.type} — ${event.sessionId}`, + emoji: true, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: event.message, + }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `*Project:* ${event.projectId} | *Priority:* ${event.priority} | *Time:* `, + }, + ], + }, + ]; + + // Add PR link if available (type-guarded) + const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined; + if (prUrl) { + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `:github: <${prUrl}|View Pull Request>`, + }, + }); + } + + // Add CI status if available (type-guarded) + const ciStatus = typeof event.data.ciStatus === "string" ? event.data.ciStatus : undefined; + if (ciStatus) { + const ciEmoji = ciStatus === "passing" ? ":white_check_mark:" : ":x:"; + blocks.push({ + type: "context", + elements: [ + { + type: "mrkdwn", + text: `${ciEmoji} CI: ${ciStatus}`, + }, + ], + }); + } + + // Add action buttons + if (actions && actions.length > 0) { + const elements = actions + .filter((a) => a.url || a.callbackEndpoint) + .map((action) => { + if (action.url) { + return { + type: "button", + text: { type: "plain_text", text: action.label, emoji: true }, + url: action.url, + }; + } + const sanitized = action.label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_|_$/g, ""); + const idx = actions.indexOf(action); + const actionId = sanitized ? `${sanitized}_${idx}` : `action_${idx}`; + return { + type: "button", + text: { type: "plain_text", text: action.label, emoji: true }, + action_id: `ao_${actionId}`, + value: action.callbackEndpoint, + }; + }); + + if (elements.length > 0) { + blocks.push({ + type: "actions", + elements, + }); + } + } + + blocks.push({ type: "divider" }); + + return blocks; +} + +async function postToWebhook(webhookUrl: string, payload: Record): Promise { + const response = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Slack webhook failed (${response.status}): ${body}`); + } +} + + +export function create(config?: Record): Notifier { + const webhookUrl = config?.webhookUrl as string | undefined; + const defaultChannel = config?.channel as string | undefined; + const username = (config?.username as string) ?? "Agent Orchestrator"; + + if (!webhookUrl) { + console.warn("[notifier-slack] No webhookUrl configured — notifications will be no-ops"); + } else { + validateUrl(webhookUrl, "notifier-slack"); + } + + return { + name: "slack", + + async notify(event: OrchestratorEvent): Promise { + if (!webhookUrl) return; + + const payload: Record = { + username, + blocks: buildBlocks(event), + }; + if (defaultChannel) payload.channel = defaultChannel; + + await postToWebhook(webhookUrl, payload); + }, + + async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { + if (!webhookUrl) return; + + const payload: Record = { + username, + blocks: buildBlocks(event, actions), + }; + if (defaultChannel) payload.channel = defaultChannel; + + await postToWebhook(webhookUrl, payload); + }, + + async post(message: string, context?: NotifyContext): Promise { + if (!webhookUrl) return null; + + const channel = context?.channel ?? defaultChannel; + const payload: Record = { + username, + text: message, + }; + if (channel) payload.channel = channel; + + await postToWebhook(webhookUrl, payload); + // Incoming webhooks don't return a message ID + return null; + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/notifier-webhook/package.json b/packages/plugins/notifier-webhook/package.json index ff42d859f..fa40d1a97 100644 --- a/packages/plugins/notifier-webhook/package.json +++ b/packages/plugins/notifier-webhook/package.json @@ -14,6 +14,7 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "vitest run", "clean": "rm -rf dist" }, "dependencies": { @@ -21,6 +22,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/plugins/notifier-webhook/src/index.test.ts b/packages/plugins/notifier-webhook/src/index.test.ts new file mode 100644 index 000000000..4b815b3aa --- /dev/null +++ b/packages/plugins/notifier-webhook/src/index.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { OrchestratorEvent, NotifyAction } from "@agent-orchestrator/core"; +import { manifest, create } from "./index.js"; + +function makeEvent(overrides: Partial = {}): OrchestratorEvent { + return { + id: "evt-1", + type: "ci.failing", + priority: "action", + sessionId: "app-1", + projectId: "my-project", + timestamp: new Date("2025-06-15T12:00:00Z"), + message: "CI check failed on app-1", + data: { checkName: "lint" }, + ...overrides, + }; +} + +describe("notifier-webhook", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("manifest", () => { + it("has correct metadata", () => { + expect(manifest.name).toBe("webhook"); + expect(manifest.slot).toBe("notifier"); + expect(manifest.version).toBe("0.1.0"); + }); + }); + + describe("create", () => { + it("returns a notifier with name 'webhook'", () => { + const notifier = create({ url: "https://example.com/hook" }); + expect(notifier.name).toBe("webhook"); + }); + + it("warns when no url configured", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + create(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No url configured")); + }); + + it("throws on invalid URL scheme", () => { + expect(() => create({ url: "ftp://example.com" })).toThrow("must be http(s)"); + }); + + it("throws on javascript: URL", () => { + expect(() => create({ url: "javascript:alert(1)" })).toThrow("must be http(s)"); + }); + + it("accepts http:// URL", () => { + expect(() => create({ url: "http://localhost:8080/hook" })).not.toThrow(); + }); + + it("accepts https:// URL", () => { + expect(() => create({ url: "https://example.com/hook" })).not.toThrow(); + }); + }); + + describe("notify", () => { + it("does nothing when no url", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const notifier = create(); + await notifier.notify(makeEvent()); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("POSTs event as JSON to the configured URL", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ url: "https://example.com/hook" }); + await notifier.notify(makeEvent()); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][0]).toBe("https://example.com/hook"); + + const opts = fetchMock.mock.calls[0][1]; + expect(opts.method).toBe("POST"); + expect(opts.headers["Content-Type"]).toBe("application/json"); + + const body = JSON.parse(opts.body); + expect(body.type).toBe("notification"); + expect(body.event.id).toBe("evt-1"); + expect(body.event.sessionId).toBe("app-1"); + expect(body.event.type).toBe("ci.failing"); + }); + + it("serializes event timestamp as ISO string", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ url: "https://example.com/hook" }); + await notifier.notify(makeEvent()); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.event.timestamp).toBe("2025-06-15T12:00:00.000Z"); + }); + + it("includes custom headers", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + url: "https://example.com/hook", + headers: { Authorization: "Bearer tok123" }, + }); + await notifier.notify(makeEvent()); + + const headers = fetchMock.mock.calls[0][1].headers; + expect(headers["Authorization"]).toBe("Bearer tok123"); + expect(headers["Content-Type"]).toBe("application/json"); + }); + }); + + describe("notifyWithActions", () => { + it("includes actions in payload", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const actions: NotifyAction[] = [ + { label: "Merge", url: "https://github.com/pull/1/merge" }, + { label: "Kill", callbackEndpoint: "/api/kill/app-1" }, + ]; + + const notifier = create({ url: "https://example.com/hook" }); + await notifier.notifyWithActions!(makeEvent(), actions); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.type).toBe("notification_with_actions"); + expect(body.actions).toHaveLength(2); + }); + }); + + describe("post", () => { + it("sends a plain message with context", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ url: "https://example.com/hook" }); + const result = await notifier.post!("All sessions complete", { + projectId: "my-project", + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.type).toBe("message"); + expect(body.message).toBe("All sessions complete"); + expect(result).toBeNull(); + }); + }); + + describe("retry logic", () => { + it("retries on 5xx and succeeds", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 503, + text: () => Promise.resolve("unavailable"), + }) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + url: "https://example.com/hook", + retries: 2, + retryDelayMs: 1, + }); + await notifier.notify(makeEvent()); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("retries on 429 Too Many Requests", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 429, + text: () => Promise.resolve("rate limited"), + }) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + url: "https://example.com/hook", + retries: 2, + retryDelayMs: 1, + }); + await notifier.notify(makeEvent()); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does NOT retry on 400 Bad Request", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 400, text: () => Promise.resolve("bad request") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + url: "https://example.com/hook", + retries: 2, + retryDelayMs: 1, + }); + await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (400)"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does NOT retry on 401 Unauthorized", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + url: "https://example.com/hook", + retries: 2, + retryDelayMs: 1, + }); + await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (401)"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does NOT retry on 403 Forbidden", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve("forbidden") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + url: "https://example.com/hook", + retries: 2, + retryDelayMs: 1, + }); + await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (403)"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does NOT retry on 404 Not Found", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 404, text: () => Promise.resolve("not found") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + url: "https://example.com/hook", + retries: 2, + retryDelayMs: 1, + }); + await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (404)"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws after all retries exhausted on 5xx", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("error") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + url: "https://example.com/hook", + retries: 2, + retryDelayMs: 1, + }); + await expect(notifier.notify(makeEvent())).rejects.toThrow( + "Webhook POST failed (500): error", + ); + // 1 initial + 2 retries = 3 + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("retries on network errors", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + url: "https://example.com/hook", + retries: 1, + retryDelayMs: 1, + }); + await notifier.notify(makeEvent()); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("respects retries=0 (no retries)", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("fail") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + url: "https://example.com/hook", + retries: 0, + retryDelayMs: 1, + }); + await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/plugins/notifier-webhook/src/index.ts b/packages/plugins/notifier-webhook/src/index.ts index 41a13afa2..50dda1e23 100644 --- a/packages/plugins/notifier-webhook/src/index.ts +++ b/packages/plugins/notifier-webhook/src/index.ts @@ -1 +1,175 @@ -// notifier-webhook plugin — to be implemented +import { + validateUrl, + type PluginModule, + type Notifier, + type OrchestratorEvent, + type NotifyAction, + type NotifyContext, +} from "@agent-orchestrator/core"; + +export const manifest = { + name: "webhook", + slot: "notifier" as const, + description: "Notifier plugin: generic HTTP webhook", + version: "0.1.0", +}; + +interface WebhookPayload { + type: "notification" | "notification_with_actions" | "message"; + event?: { + id: string; + type: string; + priority: string; + sessionId: string; + projectId: string; + timestamp: string; + message: string; + data: Record; + }; + actions?: Array<{ + label: string; + url?: string; + callbackEndpoint?: string; + }>; + message?: string; + context?: NotifyContext; +} + +/** + * Returns true if the HTTP status code should be retried. + * Only 429 (Too Many Requests) and 5xx (server errors) are retryable. + * 4xx client errors (400, 401, 403, 404, etc.) are permanent failures. + */ +function isRetryableStatus(status: number): boolean { + return status === 429 || status >= 500; +} + +async function postWithRetry( + url: string, + payload: WebhookPayload, + headers: Record, + retries: number, + retryDelayMs: number, +): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(payload), + }); + + if (response.ok) return; + + const body = await response.text(); + lastError = new Error(`Webhook POST failed (${response.status}): ${body}`); + + // Only retry on 429 or 5xx — 4xx client errors are permanent + if (!isRetryableStatus(response.status)) { + throw lastError; + } + } catch (err) { + if (err === lastError) throw err; // Re-throw non-retryable errors from above + lastError = err instanceof Error ? err : new Error(String(err)); + } + + if (attempt < retries) { + // Exponential backoff: delay * 2^attempt + const delay = retryDelayMs * 2 ** attempt; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError; +} + +function serializeEvent(event: OrchestratorEvent): WebhookPayload["event"] { + return { + id: event.id, + type: event.type, + priority: event.priority, + sessionId: event.sessionId, + projectId: event.projectId, + timestamp: event.timestamp.toISOString(), + message: event.message, + data: event.data, + }; +} + + +export function create(config?: Record): Notifier { + const url = config?.url as string | undefined; + const rawHeaders = config?.headers; + const customHeaders: Record = {}; + if (rawHeaders && typeof rawHeaders === "object" && !Array.isArray(rawHeaders)) { + for (const [k, v] of Object.entries(rawHeaders)) { + if (typeof v === "string") customHeaders[k] = v; + } + } + const rawRetries = (config?.retries as number) ?? 2; + const rawDelay = (config?.retryDelayMs as number) ?? 1000; + const retries = Number.isFinite(rawRetries) ? Math.max(0, rawRetries) : 2; + const retryDelayMs = Number.isFinite(rawDelay) && rawDelay >= 0 ? rawDelay : 1000; + + if (!url) { + console.warn("[notifier-webhook] No url configured — notifications will be no-ops"); + } else { + validateUrl(url, "notifier-webhook"); + } + + return { + name: "webhook", + + async notify(event: OrchestratorEvent): Promise { + if (!url) return; + + await postWithRetry( + url, + { type: "notification", event: serializeEvent(event) }, + customHeaders, + retries, + retryDelayMs, + ); + }, + + async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { + if (!url) return; + + await postWithRetry( + url, + { + type: "notification_with_actions", + event: serializeEvent(event), + actions: actions.map((a) => ({ + label: a.label, + url: a.url, + callbackEndpoint: a.callbackEndpoint, + })), + }, + customHeaders, + retries, + retryDelayMs, + ); + }, + + async post(message: string, context?: NotifyContext): Promise { + if (!url) return null; + + await postWithRetry( + url, + { type: "message", message, context }, + customHeaders, + retries, + retryDelayMs, + ); + return null; + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/terminal-iterm2/package.json b/packages/plugins/terminal-iterm2/package.json index 66912ff5c..8a23d6708 100644 --- a/packages/plugins/terminal-iterm2/package.json +++ b/packages/plugins/terminal-iterm2/package.json @@ -14,6 +14,7 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "vitest run", "clean": "rm -rf dist" }, "dependencies": { @@ -21,6 +22,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/plugins/terminal-iterm2/src/index.test.ts b/packages/plugins/terminal-iterm2/src/index.test.ts new file mode 100644 index 000000000..123b67f8e --- /dev/null +++ b/packages/plugins/terminal-iterm2/src/index.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import type { Session } from "@agent-orchestrator/core"; + +vi.mock("node:child_process", () => ({ + execFile: vi.fn(), +})); + +vi.mock("node:os", () => ({ + platform: vi.fn(() => "darwin"), +})); + +import { execFile } from "node:child_process"; +import { platform } from "node:os"; +import { manifest, create, escapeAppleScript } from "./index.js"; + +const mockExecFile = execFile as unknown as Mock; +const mockPlatform = platform as unknown as Mock; + +function makeSession(overrides: Partial = {}): Session { + return { + id: "app-1", + projectId: "my-project", + status: "working", + activity: "active", + branch: "feat/test", + issueId: null, + pr: null, + workspacePath: "/tmp/workspace", + runtimeHandle: null, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + ...overrides, + }; +} + +function simulateOsascript(stdout: string) { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { + cb(null, stdout); + }, + ); +} + +function simulateOsascriptSequence(results: string[]) { + let callIndex = 0; + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { + const result = results[callIndex] ?? "NOT_FOUND"; + callIndex++; + cb(null, result); + }, + ); +} + +describe("terminal-iterm2", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockPlatform.mockReturnValue("darwin"); + }); + + describe("manifest", () => { + it("has correct metadata", () => { + expect(manifest.name).toBe("iterm2"); + expect(manifest.slot).toBe("terminal"); + expect(manifest.version).toBe("0.1.0"); + }); + }); + + describe("escapeAppleScript", () => { + it("escapes double quotes", () => { + expect(escapeAppleScript('hello "world"')).toBe('hello \\"world\\"'); + }); + + it("escapes backslashes", () => { + expect(escapeAppleScript("path\\to\\file")).toBe("path\\\\to\\\\file"); + }); + + it("escapes both backslashes and quotes together", () => { + expect(escapeAppleScript('a\\b"c')).toBe('a\\\\b\\"c'); + }); + + it("returns plain strings unchanged", () => { + expect(escapeAppleScript("hello-world_123")).toBe("hello-world_123"); + }); + }); + + describe("create", () => { + it("returns a terminal with name 'iterm2'", () => { + const terminal = create(); + expect(terminal.name).toBe("iterm2"); + }); + }); + + describe("openSession", () => { + it("uses session.id as session name by default", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = create(); + await terminal.openSession(makeSession({ id: "backend-5" })); + + expect(mockExecFile).toHaveBeenCalledTimes(2); + const newTabScript = mockExecFile.mock.calls[1][1][1] as string; + expect(newTabScript).toContain("backend-5"); + }); + + it("uses runtimeHandle.id when available", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = create(); + await terminal.openSession( + makeSession({ + id: "app-1", + runtimeHandle: { id: "tmux-session-42", runtimeName: "tmux", data: {} }, + }), + ); + + const newTabScript = mockExecFile.mock.calls[1][1][1] as string; + expect(newTabScript).toContain("tmux-session-42"); + expect(newTabScript).not.toContain("app-1"); + }); + + it("reuses existing tab when found", async () => { + simulateOsascript("FOUND\n"); + const terminal = create(); + await terminal.openSession(makeSession()); + + expect(mockExecFile).toHaveBeenCalledTimes(1); + }); + + it("opens new tab when not found", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = create(); + await terminal.openSession(makeSession()); + + expect(mockExecFile).toHaveBeenCalledTimes(2); + }); + + it("escapes special chars in session name for AppleScript", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = create(); + await terminal.openSession(makeSession({ id: 'session"with"quotes' })); + + const findScript = mockExecFile.mock.calls[0][1][1] as string; + expect(findScript).toContain('session\\"with\\"quotes'); + expect(findScript).not.toContain('session"with"quotes'); + + const openScript = mockExecFile.mock.calls[1][1][1] as string; + expect(openScript).toContain('session\\"with\\"quotes'); + }); + }); + + describe("AppleScript commands", () => { + it("findAndSelectExistingTab checks session name and selects", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = create(); + await terminal.openSession(makeSession({ id: "my-session" })); + + const findScript = mockExecFile.mock.calls[0][1][1] as string; + expect(findScript).toContain('tell application "iTerm2"'); + expect(findScript).toContain("name of aSession"); + expect(findScript).not.toContain("profile name of aSession"); + expect(findScript).toContain('"my-session"'); + expect(findScript).toContain("select aWindow"); + expect(findScript).toContain("select aTab"); + }); + + it("openNewTab creates tab and attaches to tmux with shell-safe quoting", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = create(); + await terminal.openSession(makeSession({ id: "app-7" })); + + const openScript = mockExecFile.mock.calls[1][1][1] as string; + expect(openScript).toContain("create tab with default profile"); + expect(openScript).toContain('set name to "app-7"'); + expect(openScript).toContain("tmux attach -t 'app-7'"); + }); + + it("shell-escapes session names with single quotes in tmux command", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = create(); + await terminal.openSession(makeSession({ id: "it's-a-test" })); + + const openScript = mockExecFile.mock.calls[1][1][1] as string; + // Single quotes in the session name should be shell-escaped then AppleScript-escaped. + // Shell escape: 'it'\''s-a-test' → AppleScript escape doubles the backslash: 'it'\\''s-a-test' + expect(openScript).toContain("tmux attach -t 'it'\\\\''s-a-test'"); + }); + + it("always calls osascript as the command", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = create(); + await terminal.openSession(makeSession()); + + for (const call of mockExecFile.mock.calls) { + expect(call[0]).toBe("osascript"); + expect(call[1][0]).toBe("-e"); + } + }); + }); + + describe("openAll", () => { + it("does nothing for empty session list", async () => { + const terminal = create(); + await terminal.openAll([]); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it("opens tabs for each session", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = create(); + const sessions = [makeSession({ id: "app-1" }), makeSession({ id: "app-2" })]; + + const promise = terminal.openAll(sessions); + await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(300); + await promise; + + // 2 sessions * 2 calls each (find + open) = 4 + expect(mockExecFile).toHaveBeenCalledTimes(4); + }); + + it("skips opening tabs for existing sessions", async () => { + simulateOsascriptSequence(["FOUND\n", "NOT_FOUND\n", ""]); + const terminal = create(); + const sessions = [makeSession({ id: "existing-1" }), makeSession({ id: "new-1" })]; + + const promise = terminal.openAll(sessions); + await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(300); + await promise; + + // existing-1: 1 call (find=FOUND), new-1: 2 calls (find=NOT_FOUND + open) + expect(mockExecFile).toHaveBeenCalledTimes(3); + }); + }); + + describe("platform guard", () => { + it("openSession is a no-op on non-macOS", async () => { + mockPlatform.mockReturnValue("linux"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const terminal = create(); + await terminal.openSession(makeSession()); + expect(mockExecFile).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("only available on macOS")); + warnSpy.mockRestore(); + }); + + it("isSessionOpen returns false on non-macOS", async () => { + mockPlatform.mockReturnValue("win32"); + const terminal = create(); + const result = await terminal.isSessionOpen!(makeSession()); + expect(result).toBe(false); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + }); + + describe("isSessionOpen", () => { + it("returns true when tab exists", async () => { + simulateOsascript("FOUND\n"); + const terminal = create(); + const result = await terminal.isSessionOpen!(makeSession({ id: "app-1" })); + expect(result).toBe(true); + }); + + it("returns false when tab does not exist", async () => { + simulateOsascript("NOT_FOUND\n"); + const terminal = create(); + const result = await terminal.isSessionOpen!(makeSession({ id: "app-1" })); + expect(result).toBe(false); + }); + + it("returns false when osascript fails", async () => { + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => { + cb(new Error("osascript failed"), ""); + }, + ); + const terminal = create(); + const result = await terminal.isSessionOpen!(makeSession()); + expect(result).toBe(false); + }); + + it("does NOT select the tab (no side effect)", async () => { + simulateOsascript("FOUND\n"); + const terminal = create(); + await terminal.isSessionOpen!(makeSession({ id: "check-only" })); + + // isSessionOpen uses hasExistingTab which does NOT contain select commands + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).not.toContain("select aWindow"); + expect(script).not.toContain("select aTab"); + // Uses session name, not profile name + expect(script).toContain("name of aSession"); + expect(script).not.toContain("profile name"); + }); + }); +}); diff --git a/packages/plugins/terminal-iterm2/src/index.ts b/packages/plugins/terminal-iterm2/src/index.ts index 94406cc82..6ded9304a 100644 --- a/packages/plugins/terminal-iterm2/src/index.ts +++ b/packages/plugins/terminal-iterm2/src/index.ts @@ -1 +1,178 @@ -// terminal-iterm2 plugin — to be implemented +import { execFile } from "node:child_process"; +import { platform } from "node:os"; +import { + escapeAppleScript, + type PluginModule, + type Terminal, + type Session, +} from "@agent-orchestrator/core"; + +export const manifest = { + name: "iterm2", + slot: "terminal" as const, + description: "Terminal plugin: macOS iTerm2 tab management", + version: "0.1.0", +}; + +// Re-export for backwards compatibility +export { escapeAppleScript } from "@agent-orchestrator/core"; + +/** + * Run an AppleScript snippet and return stdout. + */ +function runAppleScript(script: string): Promise { + return new Promise((resolve, reject) => { + execFile("osascript", ["-e", script], (err, stdout) => { + if (err) reject(err); + else resolve(stdout.trim()); + }); + }); +} + +/** + * Escape a string for safe interpolation inside a shell single-quoted context. + * Replaces ' with '\'' (end quote, escaped quote, start quote). + */ +function shellEscape(s: string): string { + return s.replace(/'/g, "'\\''"); +} + +/** + * Check if an iTerm2 tab already exists for this session by matching session name. + * Returns true if found (and selects it), false otherwise. + */ +async function findAndSelectExistingTab(sessionName: string): Promise { + const safe = escapeAppleScript(sessionName); + const script = ` +tell application "iTerm2" + repeat with aWindow in windows + repeat with aTab in tabs of aWindow + repeat with aSession in sessions of aTab + try + if name of aSession is equal to "${safe}" then + select aWindow + select aTab + return "FOUND" + end if + end try + end repeat + end repeat + end repeat + return "NOT_FOUND" +end tell`; + + const result = await runAppleScript(script); + return result === "FOUND"; +} + +/** + * Check if an iTerm2 tab exists for this session WITHOUT selecting it. + * Pure query — no side effects on the UI. + */ +async function hasExistingTab(sessionName: string): Promise { + const safe = escapeAppleScript(sessionName); + const script = ` +tell application "iTerm2" + repeat with aWindow in windows + repeat with aTab in tabs of aWindow + repeat with aSession in sessions of aTab + try + if name of aSession is equal to "${safe}" then + return "FOUND" + end if + end try + end repeat + end repeat + end repeat + return "NOT_FOUND" +end tell`; + + const result = await runAppleScript(script); + return result === "FOUND"; +} + +/** + * Open a new iTerm2 tab and attach to the given tmux session. + * Creates a new window if no window is open. + */ +async function openNewTab(sessionName: string): Promise { + const safe = escapeAppleScript(sessionName); + // Double-escape for the write text command: shell-escape first, then AppleScript-escape + // the whole shell command so it's safe inside the AppleScript double-quoted string. + const shellSafe = shellEscape(sessionName); + const shellInAppleScript = escapeAppleScript(shellSafe); + const script = ` +tell application "iTerm2" + activate + if (count of windows) is 0 then + create window with default profile + else + tell current window + create tab with default profile + end tell + end if + tell current session of current window + set name to "${safe}" + write text "printf '\\\\033]0;${shellInAppleScript}\\\\007' && tmux attach -t '${shellInAppleScript}'" + end tell +end tell`; + + await runAppleScript(script); +} + +function getSessionName(session: Session): string { + // Use the runtime handle id if available (tmux session name), otherwise session id + return session.runtimeHandle?.id ?? session.id; +} + +function isMacOS(): boolean { + return platform() === "darwin"; +} + +export function create(): Terminal { + return { + name: "iterm2", + + async openSession(session: Session): Promise { + if (!isMacOS()) { + // eslint-disable-next-line no-console + console.warn("[terminal-iterm2] iTerm2 is only available on macOS"); + return; + } + const sessionName = getSessionName(session); + + // Try to find and select an existing tab first + const found = await findAndSelectExistingTab(sessionName); + if (!found) { + await openNewTab(sessionName); + } + }, + + async openAll(sessions: Session[]): Promise { + if (!isMacOS() || sessions.length === 0) return; + + for (const session of sessions) { + const sessionName = getSessionName(session); + const found = await findAndSelectExistingTab(sessionName); + if (!found) { + await openNewTab(sessionName); + } + // Small delay between tab operations to avoid AppleScript race conditions + await new Promise((resolve) => setTimeout(resolve, 300)); + } + }, + + async isSessionOpen(session: Session): Promise { + if (!isMacOS()) return false; + const sessionName = getSessionName(session); + try { + // Query-only check — does NOT select/focus the tab + return await hasExistingTab(sessionName); + } catch { + return false; + } + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/terminal-web/package.json b/packages/plugins/terminal-web/package.json index 9760d4a04..19cff254a 100644 --- a/packages/plugins/terminal-web/package.json +++ b/packages/plugins/terminal-web/package.json @@ -14,6 +14,7 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "vitest run", "clean": "rm -rf dist" }, "dependencies": { @@ -21,6 +22,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/plugins/terminal-web/src/index.test.ts b/packages/plugins/terminal-web/src/index.test.ts new file mode 100644 index 000000000..3ff9a29ee --- /dev/null +++ b/packages/plugins/terminal-web/src/index.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Session } from "@agent-orchestrator/core"; +import { manifest, create } from "./index.js"; + +function makeSession(overrides: Partial = {}): Session { + return { + id: "app-1", + projectId: "my-project", + status: "working", + activity: "active", + branch: "feat/test", + issueId: null, + pr: null, + workspacePath: "/tmp/workspace", + runtimeHandle: null, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + ...overrides, + }; +} + +describe("terminal-web", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe("manifest", () => { + it("has correct metadata", () => { + expect(manifest.name).toBe("web"); + expect(manifest.slot).toBe("terminal"); + expect(manifest.version).toBe("0.1.0"); + }); + }); + + describe("create", () => { + it("returns a terminal with name 'web'", () => { + const terminal = create(); + expect(terminal.name).toBe("web"); + }); + + it("has openSession, openAll, and isSessionOpen methods", () => { + const terminal = create(); + expect(typeof terminal.openSession).toBe("function"); + expect(typeof terminal.openAll).toBe("function"); + expect(typeof terminal.isSessionOpen).toBe("function"); + }); + }); + + describe("openSession", () => { + it("marks session as open", async () => { + const terminal = create(); + const session = makeSession({ id: "app-1" }); + + expect(await terminal.isSessionOpen!(session)).toBe(false); + await terminal.openSession(session); + expect(await terminal.isSessionOpen!(session)).toBe(true); + }); + + it("logs the terminal URL", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const terminal = create(); + await terminal.openSession(makeSession({ id: "backend-3" })); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("backend-3")); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("/sessions/backend-3/terminal")); + }); + + it("uses default dashboard URL", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const terminal = create(); + await terminal.openSession(makeSession()); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("http://localhost:3000")); + }); + + it("uses custom dashboard URL", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const terminal = create({ dashboardUrl: "https://my-dashboard.io" }); + await terminal.openSession(makeSession()); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("https://my-dashboard.io")); + }); + }); + + describe("openAll", () => { + it("marks all sessions as open", async () => { + const terminal = create(); + const sessions = [ + makeSession({ id: "app-1" }), + makeSession({ id: "app-2" }), + makeSession({ id: "app-3" }), + ]; + + await terminal.openAll(sessions); + + for (const session of sessions) { + expect(await terminal.isSessionOpen!(session)).toBe(true); + } + }); + + it("handles empty sessions list", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const terminal = create(); + await terminal.openAll([]); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("0 sessions")); + }); + + it("logs the count of sessions", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const terminal = create(); + await terminal.openAll([makeSession({ id: "a" }), makeSession({ id: "b" })]); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("2 sessions")); + }); + }); + + describe("isSessionOpen", () => { + it("returns false for sessions never opened", async () => { + const terminal = create(); + expect(await terminal.isSessionOpen!(makeSession({ id: "never-opened" }))).toBe(false); + }); + + it("returns true only for sessions that were opened", async () => { + const terminal = create(); + const s1 = makeSession({ id: "opened-1" }); + const s2 = makeSession({ id: "not-opened" }); + + await terminal.openSession(s1); + + expect(await terminal.isSessionOpen!(s1)).toBe(true); + expect(await terminal.isSessionOpen!(s2)).toBe(false); + }); + + it("tracks sessions independently across openSession and openAll", async () => { + const terminal = create(); + const s1 = makeSession({ id: "single" }); + const s2 = makeSession({ id: "batch-a" }); + const s3 = makeSession({ id: "batch-b" }); + const s4 = makeSession({ id: "never" }); + + await terminal.openSession(s1); + await terminal.openAll([s2, s3]); + + expect(await terminal.isSessionOpen!(s1)).toBe(true); + expect(await terminal.isSessionOpen!(s2)).toBe(true); + expect(await terminal.isSessionOpen!(s3)).toBe(true); + expect(await terminal.isSessionOpen!(s4)).toBe(false); + }); + }); +}); diff --git a/packages/plugins/terminal-web/src/index.ts b/packages/plugins/terminal-web/src/index.ts index 700cb619f..8a726895a 100644 --- a/packages/plugins/terminal-web/src/index.ts +++ b/packages/plugins/terminal-web/src/index.ts @@ -1 +1,52 @@ -// terminal-web plugin — to be implemented +import type { PluginModule, Terminal, Session } from "@agent-orchestrator/core"; + +export const manifest = { + name: "web", + slot: "terminal" as const, + description: "Terminal plugin: xterm.js web terminal", + version: "0.1.0", +}; + +/** + * Web terminal plugin. + * + * Unlike iTerm2, this doesn't directly open anything — the web dashboard + * uses xterm.js to connect to sessions via the runtime's attach info. + * This plugin provides the URL generation and session-open tracking + * that the dashboard needs. + */ +export function create(config?: Record): Terminal { + const dashboardUrl = (config?.dashboardUrl as string) ?? "http://localhost:3000"; + + // Track which sessions have been "opened" (URL generated for the dashboard) + const openSessions = new Set(); + + return { + name: "web", + + async openSession(session: Session): Promise { + openSessions.add(session.id); + // In a web context, "opening" means the dashboard should show the terminal. + // The actual xterm.js connection is handled by the web dashboard frontend + // using the session's runtime attach info. + console.log( + `[terminal-web] Session ${session.id} terminal available at ${dashboardUrl}/sessions/${session.id}/terminal`, + ); + }, + + async openAll(sessions: Session[]): Promise { + for (const session of sessions) { + openSessions.add(session.id); + } + console.log( + `[terminal-web] ${sessions.length} sessions available at ${dashboardUrl}/sessions`, + ); + }, + + async isSessionOpen(session: Session): Promise { + return openSessions.has(session.id); + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f3a0749f..ce57ed72b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,7 +84,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/integration-tests: dependencies: @@ -119,6 +119,24 @@ importers: specifier: workspace:* version: link:../plugins/workspace-worktree devDependencies: + '@agent-orchestrator/plugin-notifier-composio': + specifier: workspace:* + version: link:../plugins/notifier-composio + '@agent-orchestrator/plugin-notifier-desktop': + specifier: workspace:* + version: link:../plugins/notifier-desktop + '@agent-orchestrator/plugin-notifier-slack': + specifier: workspace:* + version: link:../plugins/notifier-slack + '@agent-orchestrator/plugin-notifier-webhook': + specifier: workspace:* + version: link:../plugins/notifier-webhook + '@agent-orchestrator/plugin-terminal-iterm2': + specifier: workspace:* + version: link:../plugins/terminal-iterm2 + '@agent-orchestrator/plugin-terminal-web': + specifier: workspace:* + version: link:../plugins/terminal-web '@types/node': specifier: ^25.2.3 version: 25.2.3 @@ -193,6 +211,25 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + packages/plugins/notifier-composio: + dependencies: + '@agent-orchestrator/core': + specifier: workspace:* + version: link:../../core + composio-core: + specifier: '>=0.5.0' + version: 0.5.39(@ai-sdk/openai@3.0.29(zod@3.25.76))(@cloudflare/workers-types@4.20260214.0)(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@langchain/openai@1.2.7(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(ws@8.19.0))(ai@6.0.86(zod@3.25.76))(langchain@1.2.24(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)))(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + devDependencies: + '@types/node': + specifier: ^25.2.3 + version: 25.2.3 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + packages/plugins/notifier-desktop: dependencies: '@agent-orchestrator/core': @@ -205,6 +242,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/notifier-slack: dependencies: @@ -218,6 +258,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/notifier-webhook: dependencies: @@ -231,6 +274,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/runtime-process: dependencies: @@ -292,6 +338,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/terminal-web: dependencies: @@ -305,6 +354,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/tracker-github: dependencies: @@ -377,7 +429,7 @@ importers: version: link:../core next: specifier: ^15.1.0 - version: 15.5.12(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: ^19.0.0 version: 19.2.4 @@ -427,6 +479,28 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/gateway@3.0.46': + resolution: {integrity: sha512-zH1UbNRjG5woOXXFOrVCZraqZuFTtmPvLardMGcgLkzpxKV0U3tAGoyWKSZ862H+eBJfI/Hf2yj/zzGJcCkycg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai@3.0.29': + resolution: {integrity: sha512-ugVTIVpuSLKTjzSPe1F1DWiblJT/lwrrHx0OZEKjpMk/EYP6j6VD/F7SJqM1dsqOJryeBCJWFbUzLNqc99PrMA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.15': + resolution: {integrity: sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -521,6 +595,16 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + + '@cloudflare/workers-types@4.20260214.0': + resolution: {integrity: sha512-qb8rgbAdJR4BAPXolXhFL/wuGtecHLh1veOyZ1mK6QqWuCdI3vK1biKC0i3lzmzdLR/DZvsN3mNtpUE8zpWGEg==} + + '@composio/mcp@1.0.3-0': + resolution: {integrity: sha512-IpbfST0SSs/CEv+PIf6+EL0feNuJhQyUrOHJLPge8NhyLLrCyVqvujRIPyRUUjy0NDsked/Mm5VpJmYM6OACbg==} + hasBin: true + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -885,6 +969,12 @@ packages: resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@hey-api/client-axios@0.2.12': + resolution: {integrity: sha512-lBehVhbnhvm41cFguZuy1FO+4x8NO3Qy/ooL0Jw4bdqTu21n7DmZMPsXEF0gL7/gNdTt4QkJGwaojy+8ExtE8w==} + deprecated: Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts. + peerDependencies: + axios: '>= 1.0.0 < 2' + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1038,6 +1128,66 @@ packages: cpu: [x64] os: [win32] + '@inquirer/checkbox@2.5.0': + resolution: {integrity: sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA==} + engines: {node: '>=18'} + + '@inquirer/confirm@3.2.0': + resolution: {integrity: sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==} + engines: {node: '>=18'} + + '@inquirer/core@9.2.1': + resolution: {integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==} + engines: {node: '>=18'} + + '@inquirer/editor@2.2.0': + resolution: {integrity: sha512-9KHOpJ+dIL5SZli8lJ6xdaYLPPzB8xB9GZItg39MBybzhxA16vxmszmQFrRwbOA918WA2rvu8xhDEg/p6LXKbw==} + engines: {node: '>=18'} + + '@inquirer/expand@2.3.0': + resolution: {integrity: sha512-qnJsUcOGCSG1e5DTOErmv2BPQqrtT6uzqn1vI/aYGiPKq+FgslGZmtdnXbhuI7IlT7OByDoEEqdnhUnVR2hhLw==} + engines: {node: '>=18'} + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@2.3.0': + resolution: {integrity: sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==} + engines: {node: '>=18'} + + '@inquirer/number@1.1.0': + resolution: {integrity: sha512-ilUnia/GZUtfSZy3YEErXLJ2Sljo/mf9fiKc08n18DdwdmDbOzRcTv65H1jjDvlsAuvdFXf4Sa/aL7iw/NanVA==} + engines: {node: '>=18'} + + '@inquirer/password@2.2.0': + resolution: {integrity: sha512-5otqIpgsPYIshqhgtEwSspBQE40etouR8VIxzpJkv9i0dVHIpyhiivbkH9/dGiMLdyamT54YRdGJLfl8TFnLHg==} + engines: {node: '>=18'} + + '@inquirer/prompts@5.5.0': + resolution: {integrity: sha512-BHDeL0catgHdcHbSFFUddNzvx/imzJMft+tWDPwTm3hfu8/tApk1HrooNngB2Mb4qY+KaRWF+iZqoVUPeslEog==} + engines: {node: '>=18'} + + '@inquirer/rawlist@2.3.0': + resolution: {integrity: sha512-zzfNuINhFF7OLAtGHfhwOW2TlYJyli7lOUoJUXw/uyklcwalV6WRXBXtFIicN8rTRK1XTiPWB4UY+YuW8dsnLQ==} + engines: {node: '>=18'} + + '@inquirer/search@1.1.0': + resolution: {integrity: sha512-h+/5LSj51dx7hp5xOn4QFnUaKeARwUCLs6mIhtkJ0JYPBLmEYjdHSYh7I6GrLg9LwpJ3xeX0FZgAG1q0QdCpVQ==} + engines: {node: '>=18'} + + '@inquirer/select@2.5.0': + resolution: {integrity: sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA==} + engines: {node: '>=18'} + + '@inquirer/type@1.5.5': + resolution: {integrity: sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==} + engines: {node: '>=18'} + + '@inquirer/type@2.0.0': + resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} + engines: {node: '>=18'} + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -1058,6 +1208,47 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@langchain/core@1.1.24': + resolution: {integrity: sha512-u6l0dmMHN/2PCsY6stXoh9CH1OTlVR5Gjz0JjT1XRPuidAlu3kTq4ivW95xCog/PRhiAsCh6GCEC4/PqhNrcgQ==} + engines: {node: '>=20'} + + '@langchain/langgraph-checkpoint@1.0.0': + resolution: {integrity: sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': ^1.0.1 + + '@langchain/langgraph-sdk@1.6.2': + resolution: {integrity: sha512-UzRZsnDqdTmeitf/K5yZnVdl+V+7bDj/hQUXm+Y8TwWUuKtWUDocIReKgAmPQLoIz0AN8bOUt0QGnIISmCZyuA==} + peerDependencies: + '@langchain/core': ^1.1.16 + react: ^18 || ^19 + react-dom: ^18 || ^19 + peerDependenciesMeta: + '@langchain/core': + optional: true + react: + optional: true + react-dom: + optional: true + + '@langchain/langgraph@1.1.4': + resolution: {integrity: sha512-9OhRF+7Zvcpure8TLtBrxfJDo0PAoHZhfzcPL6M3CsGXiYqLWm5tQe+FYqn9zRIV7IwphqVEl1QDNbOkVgo+kw==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': ^1.1.16 + zod: ^3.25.32 || ^4.2.0 + zod-to-json-schema: ^3.x + peerDependenciesMeta: + zod-to-json-schema: + optional: true + + '@langchain/openai@1.2.7': + resolution: {integrity: sha512-vR9zoF0/EZ03X0Tc6woIEWRDSDSr2l64n+MQCW8NduScJtBJs5r/Ng3Lrp2bjtJQywEMQoOhcrV2DMmAIPWgnw==} + engines: {node: '>=20'} + peerDependencies: + '@langchain/core': ^1.0.0 + '@next/env@15.5.12': resolution: {integrity: sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==} @@ -1109,6 +1300,10 @@ packages: cpu: [x64] os: [win32] + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1384,6 +1579,12 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mute-stream@0.0.4': + resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} + + '@types/node@22.19.11': + resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} + '@types/node@25.2.3': resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} @@ -1395,6 +1596,12 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@types/wrap-ansi@3.0.0': + resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} + '@typescript-eslint/eslint-plugin@8.55.0': resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1454,6 +1661,10 @@ packages: resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1561,9 +1772,19 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ai@6.0.86: + resolution: {integrity: sha512-U2W2LBCHA/pr0Ui7vmmsjBiLEzBbZF3yVHNy7Rbzn7IX+SvoQPFM5rN74hhfVzZoE8zBuGD4nLLk+j0elGacvQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1572,6 +1793,10 @@ packages: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} @@ -1590,6 +1815,9 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + axios@1.13.5: + resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1597,6 +1825,9 @@ packages: resolution: {integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==} engines: {node: 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true @@ -1621,6 +1852,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + caniuse-lite@1.0.30001769: resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} @@ -1632,10 +1867,17 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -1644,21 +1886,56 @@ packages: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} + cli-progress@3.12.0: + resolution: {integrity: sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==} + engines: {node: '>=4'} + cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + composio-core@0.5.39: + resolution: {integrity: sha512-7BeSFlfRzr1cbIfGYJW4jQ3BHwaObOaFKiRJIFuWOmvOrTABl1hbxGkWPA3C+uFw9CFXbZhrLWNyD7lhYy2Scg==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + hasBin: true + peerDependencies: + '@ai-sdk/openai': '>=0.0.36' + '@cloudflare/workers-types': '>=4.20240718.0' + '@langchain/core': '>=0.2.18' + '@langchain/openai': '>=0.2.5' + ai: '>=3.2.22' + langchain: '>=0.2.11' + openai: '>=4.50.0' + + console-table-printer@2.15.0: + resolution: {integrity: sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1689,6 +1966,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -1699,6 +1980,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -1727,6 +2012,9 @@ packages: emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enhanced-resolve@5.19.0: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} @@ -1827,10 +2115,24 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1864,6 +2166,15 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -1911,6 +2222,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1935,6 +2250,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -1955,10 +2274,23 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inquirer@10.2.2: + resolution: {integrity: sha512-tyao/4Vo36XnUItZ7DnUXX4f1jVao2mSrleV/5IPtW/XAEA26hRVsbc68nuTEKWcr5vMP/1mVoT2O7u8H4v1Vg==} + engines: {node: '>=18'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1967,6 +2299,10 @@ packages: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} + is-network-error@1.3.0: + resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==} + engines: {node: '>=16'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -1978,6 +2314,10 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1989,6 +2329,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-tiktoken@1.0.21: + resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2015,6 +2358,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2026,6 +2372,29 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + langchain@1.2.24: + resolution: {integrity: sha512-NpaOmDZ4dP16sLkY+Y49Q9mrV2fdTy81t+yTw7f3K7/zZtvuQk6IH/bIzfy3bdOD8TERethvR2mOmgueRDbZBw==} + engines: {node: '>=20'} + peerDependencies: + '@langchain/core': ^1.1.24 + + langsmith@0.5.4: + resolution: {integrity: sha512-qYkNIoKpf0ZYt+cYzrDV+XI3FCexApmZmp8EMs3eDTMv0OvrHMLoxJ9IpkeoXJSX24+GPk0/jXjKx2hWerpy9w==} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2155,6 +2524,14 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + + mute-stream@1.0.0: + resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2197,6 +2574,22 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + openai@6.22.0: + resolution: {integrity: sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2205,6 +2598,14 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -2213,6 +2614,26 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-queue@9.1.0: + resolution: {integrity: sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==} + engines: {node: '>=20'} + + p-retry@7.1.1: + resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==} + engines: {node: '>=20'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -2224,6 +2645,14 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-root-regex@0.1.2: + resolution: {integrity: sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==} + engines: {node: '>=0.10.0'} + + path-root@0.1.1: + resolution: {integrity: sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==} + engines: {node: '>=0.10.0'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -2272,10 +2701,16 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pusher-js@8.4.0-rc2: + resolution: {integrity: sha512-d87GjOEEl9QgO5BWmViSqW0LOzPvybvX6WA9zLUstNdB57jVJuR27zHkRnrav2a3+zAMlHbP2Og8wug+rG8T+g==} + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -2296,6 +2731,10 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + resolve-package-path@4.0.3: + resolution: {integrity: sha512-SRpNAPW4kewOaNUt8VPqhJ0UMxawMwzJD8V7m1cJfdSTK9ieZwS6K7Dabsm4bmLFM96Z5Y/UznrpG5kt1im8yA==} + engines: {node: '>= 12'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2314,6 +2753,13 @@ packages: rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-async@3.0.0: + resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} + engines: {node: '>=0.12.0'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2352,6 +2798,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-wcswidth@1.1.2: + resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2366,10 +2815,18 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-ansi@7.1.2: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} @@ -2394,6 +2851,10 @@ packages: babel-plugin-macros: optional: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -2449,6 +2910,10 @@ packages: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -2471,10 +2936,17 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + typescript-eslint@8.55.0: resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2487,6 +2959,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -2499,6 +2974,14 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2702,6 +3185,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -2733,6 +3220,15 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -2740,6 +3236,30 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@ai-sdk/gateway@3.0.46(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@3.25.76) + '@vercel/oidc': 3.1.0 + zod: 3.25.76 + + '@ai-sdk/openai@3.0.29(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@3.25.76) + zod: 3.25.76 + + '@ai-sdk/provider-utils@4.0.15(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@alloc/quick-lru@5.2.0': {} '@asamuzakjp/css-color@3.2.0': @@ -2864,6 +3384,12 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@cfworker/json-schema@4.1.1': {} + + '@cloudflare/workers-types@4.20260214.0': {} + + '@composio/mcp@1.0.3-0': {} + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -3070,6 +3596,10 @@ snapshots: '@eslint/core': 1.1.0 levn: 0.4.1 + '@hey-api/client-axios@0.2.12(axios@1.13.5)': + dependencies: + axios: 1.13.5 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -3178,6 +3708,106 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@inquirer/checkbox@2.5.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 1.5.5 + ansi-escapes: 4.3.2 + yoctocolors-cjs: 2.1.3 + + '@inquirer/confirm@3.2.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + + '@inquirer/core@9.2.1': + dependencies: + '@inquirer/figures': 1.0.15 + '@inquirer/type': 2.0.0 + '@types/mute-stream': 0.0.4 + '@types/node': 22.19.11 + '@types/wrap-ansi': 3.0.0 + ansi-escapes: 4.3.2 + cli-width: 4.1.0 + mute-stream: 1.0.0 + signal-exit: 4.1.0 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + + '@inquirer/editor@2.2.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + external-editor: 3.1.0 + + '@inquirer/expand@2.3.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + yoctocolors-cjs: 2.1.3 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@2.3.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + + '@inquirer/number@1.1.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + + '@inquirer/password@2.2.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + ansi-escapes: 4.3.2 + + '@inquirer/prompts@5.5.0': + dependencies: + '@inquirer/checkbox': 2.5.0 + '@inquirer/confirm': 3.2.0 + '@inquirer/editor': 2.2.0 + '@inquirer/expand': 2.3.0 + '@inquirer/input': 2.3.0 + '@inquirer/number': 1.1.0 + '@inquirer/password': 2.2.0 + '@inquirer/rawlist': 2.3.0 + '@inquirer/search': 1.1.0 + '@inquirer/select': 2.5.0 + + '@inquirer/rawlist@2.3.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + yoctocolors-cjs: 2.1.3 + + '@inquirer/search@1.1.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 1.5.5 + yoctocolors-cjs: 2.1.3 + + '@inquirer/select@2.5.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 1.5.5 + ansi-escapes: 4.3.2 + yoctocolors-cjs: 2.1.3 + + '@inquirer/type@1.5.5': + dependencies: + mute-stream: 1.0.0 + + '@inquirer/type@2.0.0': + dependencies: + mute-stream: 1.0.0 + '@isaacs/cliui@9.0.0': {} '@jridgewell/gen-mapping@0.3.13': @@ -3199,6 +3829,63 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76))': + dependencies: + '@cfworker/json-schema': 4.1.1 + ansi-styles: 5.2.0 + camelcase: 6.3.0 + decamelize: 1.2.0 + js-tiktoken: 1.0.21 + langsmith: 0.5.4(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + mustache: 4.2.0 + p-queue: 6.6.2 + uuid: 10.0.0 + zod: 3.25.76 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + + '@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))': + dependencies: + '@langchain/core': 1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + uuid: 10.0.0 + + '@langchain/langgraph-sdk@1.6.2(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@types/json-schema': 7.0.15 + p-queue: 9.1.0 + p-retry: 7.1.1 + uuid: 13.0.0 + optionalDependencies: + '@langchain/core': 1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@langchain/langgraph@1.1.4(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76)': + dependencies: + '@langchain/core': 1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76))) + '@langchain/langgraph-sdk': 1.6.2(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@standard-schema/spec': 1.1.0 + uuid: 10.0.0 + zod: 3.25.76 + optionalDependencies: + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - react + - react-dom + + '@langchain/openai@1.2.7(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(ws@8.19.0)': + dependencies: + '@langchain/core': 1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + js-tiktoken: 1.0.21 + openai: 6.22.0(ws@8.19.0)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - ws + '@next/env@15.5.12': {} '@next/swc-darwin-arm64@15.5.12': @@ -3225,6 +3912,8 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.12': optional: true + '@opentelemetry/api@1.9.0': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.57.1': @@ -3443,6 +4132,14 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/mute-stream@0.0.4': + dependencies: + '@types/node': 25.2.3 + + '@types/node@22.19.11': + dependencies: + undici-types: 6.21.0 + '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -3455,6 +4152,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/uuid@10.0.0': {} + + '@types/wrap-ansi@3.0.0': {} + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3546,6 +4247,8 @@ snapshots: '@typescript-eslint/types': 8.55.0 eslint-visitor-keys: 4.2.1 + '@vercel/oidc@3.1.0': {} + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.2.3)(lightningcss@1.30.2))': dependencies: '@babel/core': 7.29.0 @@ -3687,6 +4390,14 @@ snapshots: agent-base@7.1.4: {} + ai@6.0.86(zod@3.25.76): + dependencies: + '@ai-sdk/gateway': 3.0.46(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@3.25.76) + '@opentelemetry/api': 1.9.0 + zod: 3.25.76 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3694,10 +4405,18 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@5.2.0: {} aria-query@5.3.0: @@ -3710,12 +4429,22 @@ snapshots: asynckit@0.4.0: {} + axios@1.13.5: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + balanced-match@1.0.2: {} balanced-match@4.0.2: dependencies: jackspeak: 4.2.3 + base64-js@1.5.1: {} + baseline-browser-mapping@2.9.19: {} brace-expansion@2.0.2: @@ -3741,6 +4470,8 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + camelcase@6.3.0: {} + caniuse-lite@1.0.30001769: {} chai@5.3.3: @@ -3753,24 +4484,74 @@ snapshots: chai@6.2.2: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@5.6.2: {} + chardet@0.7.0: {} + check-error@2.1.3: {} cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 + cli-progress@3.12.0: + dependencies: + string-width: 4.2.3 + cli-spinners@2.9.2: {} + cli-width@4.1.0: {} + client-only@0.0.1: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 + commander@12.1.0: {} + commander@13.1.0: {} + composio-core@0.5.39(@ai-sdk/openai@3.0.29(zod@3.25.76))(@cloudflare/workers-types@4.20260214.0)(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@langchain/openai@1.2.7(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(ws@8.19.0))(ai@6.0.86(zod@3.25.76))(langchain@1.2.24(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)))(openai@6.22.0(ws@8.19.0)(zod@3.25.76)): + dependencies: + '@ai-sdk/openai': 3.0.29(zod@3.25.76) + '@cloudflare/workers-types': 4.20260214.0 + '@composio/mcp': 1.0.3-0 + '@hey-api/client-axios': 0.2.12(axios@1.13.5) + '@langchain/core': 1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + '@langchain/openai': 1.2.7(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(ws@8.19.0) + ai: 6.0.86(zod@3.25.76) + axios: 1.13.5 + chalk: 4.1.2 + cli-progress: 3.12.0 + commander: 12.1.0 + inquirer: 10.2.2 + langchain: 1.2.24(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)) + open: 8.4.2 + openai: 6.22.0(ws@8.19.0)(zod@3.25.76) + pusher-js: 8.4.0-rc2 + resolve-package-path: 4.0.3 + uuid: 10.0.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - debug + + console-table-printer@2.15.0: + dependencies: + simple-wcswidth: 1.1.2 + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -3797,12 +4578,16 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.6.0: {} deep-eql@5.0.2: {} deep-is@0.1.4: {} + define-lazy-prop@2.0.0: {} + delayed-stream@1.0.0: {} dequal@2.0.3: {} @@ -3823,6 +4608,8 @@ snapshots: emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} + enhanced-resolve@5.19.0: dependencies: graceful-fs: 4.2.11 @@ -3982,8 +4769,20 @@ snapshots: esutils@2.0.3: {} + eventemitter3@4.0.7: {} + + eventemitter3@5.0.4: {} + + eventsource-parser@3.0.6: {} + expect-type@1.3.0: {} + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -4010,6 +4809,8 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.11: {} + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -4060,6 +4861,8 @@ snapshots: graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -4088,6 +4891,10 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -4100,20 +4907,41 @@ snapshots: indent-string@4.0.0: {} + inquirer@10.2.2: + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/prompts': 5.5.0 + '@inquirer/type': 1.5.5 + '@types/mute-stream': 0.0.4 + ansi-escapes: 4.3.2 + mute-stream: 1.0.0 + run-async: 3.0.0 + rxjs: 7.8.2 + + is-docker@2.2.1: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 is-interactive@2.0.0: {} + is-network-error@1.3.0: {} + is-potential-custom-element-name@1.0.1: {} is-unicode-supported@1.3.0: {} is-unicode-supported@2.1.0: {} + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isexe@2.0.0: {} jackspeak@4.2.3: @@ -4122,6 +4950,10 @@ snapshots: jiti@2.6.1: {} + js-tiktoken@1.0.21: + dependencies: + base64-js: 1.5.1 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -4160,6 +4992,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -4168,6 +5002,35 @@ snapshots: dependencies: json-buffer: 3.0.1 + langchain@1.2.24(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)): + dependencies: + '@langchain/core': 1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + '@langchain/langgraph': 1.1.4(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76))(zod@3.25.76) + '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76))) + langsmith: 0.5.4(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)) + uuid: 10.0.0 + zod: 3.25.76 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - react + - react-dom + - zod-to-json-schema + + langsmith@0.5.4(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)): + dependencies: + '@types/uuid': 10.0.0 + chalk: 4.1.2 + console-table-printer: 2.15.0 + p-queue: 6.6.2 + semver: 7.7.4 + uuid: 10.0.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + openai: 6.22.0(ws@8.19.0)(zod@3.25.76) + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -4267,11 +5130,15 @@ snapshots: ms@2.1.3: {} + mustache@4.2.0: {} + + mute-stream@1.0.0: {} + nanoid@3.3.11: {} natural-compare@1.4.0: {} - next@15.5.12(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 15.5.12 '@swc/helpers': 0.5.15 @@ -4289,6 +5156,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.12 '@next/swc-win32-arm64-msvc': 15.5.12 '@next/swc-win32-x64-msvc': 15.5.12 + '@opentelemetry/api': 1.9.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -4304,6 +5172,17 @@ snapshots: dependencies: mimic-function: 5.0.1 + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + openai@6.22.0(ws@8.19.0)(zod@3.25.76): + optionalDependencies: + ws: 8.19.0 + zod: 3.25.76 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -4325,6 +5204,10 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + os-tmpdir@1.0.2: {} + + p-finally@1.0.0: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -4333,6 +5216,26 @@ snapshots: dependencies: p-limit: 3.1.0 + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-queue@9.1.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-retry@7.1.1: + dependencies: + is-network-error: 1.3.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + p-timeout@7.0.1: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -4341,6 +5244,12 @@ snapshots: path-key@3.1.1: {} + path-root-regex@0.1.2: {} + + path-root@0.1.1: + dependencies: + path-root-regex: 0.1.2 + pathe@1.1.2: {} pathe@2.0.3: {} @@ -4381,8 +5290,14 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} + pusher-js@8.4.0-rc2: + dependencies: + tweetnacl: 1.0.3 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -4399,6 +5314,10 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + resolve-package-path@4.0.3: + dependencies: + path-root: 0.1.1 + resolve-pkg-maps@1.0.0: {} restore-cursor@5.1.0: @@ -4441,6 +5360,12 @@ snapshots: rrweb-cssom@0.8.0: {} + run-async@3.0.0: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safer-buffer@2.1.2: {} saxes@6.0.0: @@ -4495,6 +5420,8 @@ snapshots: signal-exit@4.1.0: {} + simple-wcswidth@1.1.2: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -4503,12 +5430,22 @@ snapshots: stdin-discarder@0.2.2: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 get-east-asian-width: 1.4.0 strip-ansi: 7.1.2 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-ansi@7.1.2: dependencies: ansi-regex: 6.2.2 @@ -4528,6 +5465,10 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + symbol-tree@3.2.4: {} tailwindcss@4.1.18: {} @@ -4563,6 +5504,10 @@ snapshots: dependencies: tldts-core: 6.1.86 + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -4584,10 +5529,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tweetnacl@1.0.3: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 + type-fest@0.21.3: {} + typescript-eslint@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) @@ -4601,6 +5550,8 @@ snapshots: typescript@5.9.3: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -4613,6 +5564,10 @@ snapshots: dependencies: punycode: 2.3.1 + uuid@10.0.0: {} + + uuid@13.0.0: {} + vite-node@2.1.9(@types/node@25.2.3)(lightningcss@1.30.2): dependencies: cac: 6.7.14 @@ -4756,7 +5711,7 @@ snapshots: - tsx - yaml - vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) @@ -4779,6 +5734,7 @@ snapshots: vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.0 '@types/node': 25.2.3 jsdom: 25.0.1 transitivePeerDependencies: @@ -4822,6 +5778,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + ws@8.19.0: {} xml-name-validator@5.0.0: {} @@ -4834,4 +5796,10 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors-cjs@2.1.3: {} + + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {}