diff --git a/.github/workflows/remote-release-platforms.yml b/.github/workflows/remote-release-platforms.yml new file mode 100644 index 00000000..06276e18 --- /dev/null +++ b/.github/workflows/remote-release-platforms.yml @@ -0,0 +1,173 @@ +name: Remote Release Platforms + +on: + workflow_dispatch: + inputs: + release_tag: + description: Draft GitHub release tag used as the transport bucket + required: true + type: string + release_title: + description: Human-readable title for the draft release + required: false + type: string + +concurrency: + group: remote-release-${{ github.ref }}-${{ inputs.release_tag }} + cancel-in-progress: false + +env: + PNPM_VERSION: "10.24.0" + NODE_VERSION: "22" + +jobs: + build-windows: + name: Windows Universal Installer + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build electron-vite + run: pnpm exec electron-vite build + - name: Package + run: pnpm exec electron-builder --win --x64 --ia32 --arm64 --config electron-builder.yml --publish never + env: + CSC_LINK: ${{ secrets.WIN_CERTIFICATE_P12_BASE64 }} + CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }} + - name: Upload release files + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_TITLE: ${{ inputs.release_title }} + run: | + set -euo pipefail + gh release view "$RELEASE_TAG" >/dev/null 2>&1 || \ + gh release create "$RELEASE_TAG" \ + --draft \ + --prerelease \ + --title "${RELEASE_TITLE:-Remote Release $GITHUB_RUN_ID}" \ + --notes "Temporary release transport generated by remote-release-platforms.yml." \ + --target "$GITHUB_SHA" + + shopt -s nullglob + files=( + build/release/*.exe + build/release/*.blockmap + build/release/latest.yml + ) + if [ ${#files[@]} -eq 0 ]; then + echo "No Windows release files found." + exit 1 + fi + + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber + + build-linux-x64: + name: Linux x64 + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build electron-vite + run: pnpm exec electron-vite build + - name: Package + run: pnpm exec electron-builder --linux --x64 --config electron-builder.yml --publish never + - name: Upload release files + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_TITLE: ${{ inputs.release_title }} + run: | + set -euo pipefail + gh release view "$RELEASE_TAG" >/dev/null 2>&1 || \ + gh release create "$RELEASE_TAG" \ + --draft \ + --prerelease \ + --title "${RELEASE_TITLE:-Remote Release $GITHUB_RUN_ID}" \ + --notes "Temporary release transport generated by remote-release-platforms.yml." \ + --target "$GITHUB_SHA" + + shopt -s nullglob + files=() + for file in build/release/AgentCompany-*.AppImage; do + if [[ "$file" != *arm64* ]]; then + files+=("$file") + fi + done + files+=( + build/release/agent-company_*_amd64.deb + build/release/agent-company-*.x86_64.rpm + build/release/latest-linux.yml + ) + if [ ${#files[@]} -eq 0 ]; then + echo "No Linux x64 release files found." + exit 1 + fi + + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber + + build-linux-arm64: + name: Linux arm64 + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build electron-vite + run: pnpm exec electron-vite build + - name: Package + run: pnpm exec electron-builder --linux --arm64 --config electron-builder.yml --publish never + - name: Upload release files + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_TITLE: ${{ inputs.release_title }} + run: | + set -euo pipefail + gh release view "$RELEASE_TAG" >/dev/null 2>&1 || \ + gh release create "$RELEASE_TAG" \ + --draft \ + --prerelease \ + --title "${RELEASE_TITLE:-Remote Release $GITHUB_RUN_ID}" \ + --notes "Temporary release transport generated by remote-release-platforms.yml." \ + --target "$GITHUB_SHA" + + shopt -s nullglob + files=( + build/release/*arm64*.AppImage + build/release/*arm64*.deb + build/release/*aarch64*.rpm + ) + if [ ${#files[@]} -eq 0 ]; then + echo "No Linux arm64 release files found." + exit 1 + fi + + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber diff --git a/.gitignore b/.gitignore index 725b0fe6..dc216ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -70,7 +70,4 @@ npm-debug.log* pnpm-debug.log* yarn-debug.log* yarn-error.log* -build/release/AgentCompany-0.4.61-arm64.dmg -CLAUDE.md -build/release/AgentCompany-0.4.90-arm64.dmg local-release/ diff --git a/build/electron-builder.runtime.json b/build/electron-builder.runtime.json index efe328d4..ecf3d432 100644 --- a/build/electron-builder.runtime.json +++ b/build/electron-builder.runtime.json @@ -1,16 +1,19 @@ { - "extends": "/project/electron-builder.yml", + "extends": "/Users/zacharyzhang/Documents/Github/AgentCompany/electron-builder.yml", "directories": { - "output": "/project/build/release" + "output": "/Users/zacharyzhang/Documents/Github/AgentCompany/build/release" }, "extraMetadata": { - "version": "0.4.150" + "version": "0.4.151" }, - "electronDist": "/root/.cache/electron", "publish": [ { "provider": "generic", "url": "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC" } - ] + ], + "mac": { + "hardenedRuntime": true, + "sign": "/Users/zacharyzhang/Documents/Github/AgentCompany/scripts/electron-builder-mac-sign.mjs" + } } diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 584e7bd5..3cbd8227 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ rollupOptions: { input: { index: resolve("src/main/index.ts"), - worker: resolve("src/main/orchestrator/worker.ts"), + worker: resolve("src/main/worker/worker-entry.ts"), }, }, }, diff --git a/landing/components/download-section.tsx b/landing/components/download-section.tsx index 81c97390..a9fa2d6e 100644 --- a/landing/components/download-section.tsx +++ b/landing/components/download-section.tsx @@ -1,10 +1,38 @@ "use client"; +import { useEffect, useState } from "react"; import { motion } from "framer-motion"; import { AsciiBackground } from "./ascii-background"; import { platformDownloads, releaseVersion } from "./release-links.generated"; +import { DOWNLOAD_COUNT_SEED, getTrackedDownloadHref } from "../lib/downloads"; export function DownloadSection() { + const [downloadCount, setDownloadCount] = useState(DOWNLOAD_COUNT_SEED); + + useEffect(() => { + let cancelled = false; + + async function loadDownloadCount() { + try { + const response = await fetch("/api/download-stats", { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Failed to fetch download count: ${response.status}`); + } + const data = await response.json() as { downloads?: number }; + if (!cancelled && typeof data.downloads === "number") { + setDownloadCount(data.downloads); + } + } catch { + // Keep the seeded count visible when the dynamic endpoint is unavailable. + } + } + + void loadDownloadCount(); + return () => { + cancelled = true; + }; + }, []); + return (

- Full release matrix hosted on our Cloudflare release lane. Version {releaseVersion}. + Version {releaseVersion}. +

+

+ Downloads: {downloadCount.toLocaleString("en-US")}

@@ -60,20 +91,16 @@ export function DownloadSection() {

{download.title}

-

{download.note}

{download.assets.map((asset) => ( {asset.label} - - {asset.note} - ))}
diff --git a/landing/components/footer.tsx b/landing/components/footer.tsx index 52760435..dd681a61 100644 --- a/landing/components/footer.tsx +++ b/landing/components/footer.tsx @@ -1,20 +1,8 @@ import { AsciiBackground } from "./ascii-background"; import { IconMark } from "./icon-mark"; import { CONTACT_EMAIL, WEBSITE_URL } from "./site-constants"; -import { platformDownloads } from "./release-links.generated"; import { ThemeToggle } from "./theme-toggle"; - -function findAsset(platformId: string, assetId: string) { - const platform = platformDownloads.find((entry) => entry.id === platformId); - if (!platform) { - throw new Error(`Missing platform download entry for ${platformId}`); - } - const asset = platform.assets.find((entry) => entry.id === assetId); - if (!asset) { - throw new Error(`Missing asset ${assetId} for ${platformId}`); - } - return asset.href; -} +import { getTrackedDownloadHref } from "../lib/downloads"; const footerGroups = [ { @@ -29,10 +17,10 @@ const footerGroups = [ { title: "Downloads", links: [ - { label: "Apple Silicon", href: findAsset("macos", "macos-arm64-dmg"), external: true }, - { label: "Intel Mac", href: findAsset("macos", "macos-x64-dmg"), external: true }, - { label: "Windows Installer", href: findAsset("windows", "windows-universal-installer"), external: true }, - { label: "Linux x64 AppImage", href: findAsset("linux", "linux-x64-appimage"), external: true }, + { label: "Apple Silicon", href: getTrackedDownloadHref("macos-arm64-dmg"), external: true }, + { label: "Intel Mac", href: getTrackedDownloadHref("macos-x64-dmg"), external: true }, + { label: "Windows Installer", href: getTrackedDownloadHref("windows-universal-installer"), external: true }, + { label: "Linux x64 AppImage", href: getTrackedDownloadHref("linux-x64-appimage"), external: true }, ], }, { @@ -91,9 +79,6 @@ export function Footer() {
© {year} AgentCompany - - Cloudflare-hosted desktop builds -
diff --git a/landing/components/hero.tsx b/landing/components/hero.tsx index 4380b715..70f9d40a 100644 --- a/landing/components/hero.tsx +++ b/landing/components/hero.tsx @@ -54,7 +54,7 @@ export function Hero() {
- Local-First · Desktop App + Agent Company diff --git a/landing/components/release-links.generated.ts b/landing/components/release-links.generated.ts index e86c20e5..6a273c61 100644 --- a/landing/components/release-links.generated.ts +++ b/landing/components/release-links.generated.ts @@ -12,7 +12,7 @@ export type PlatformDownload = { assets: PlatformDownloadAsset[]; }; -export const releaseVersion = "0.4.150"; +export const releaseVersion = "0.4.151"; export const platformDownloads: PlatformDownload[] = [ { @@ -23,13 +23,13 @@ export const platformDownloads: PlatformDownload[] = [ { id: "macos-arm64-dmg", label: "Apple Silicon DMG", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.150-arm64.dmg", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.151-arm64.dmg", note: "arm64", }, { id: "macos-x64-dmg", label: "Intel Mac DMG", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.150.dmg", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.151.dmg", note: "x64", }, ], @@ -42,7 +42,7 @@ export const platformDownloads: PlatformDownload[] = [ { id: "windows-universal-installer", label: "Windows Universal Installer", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany.Setup.0.4.150.exe", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany.Setup.0.4.151.exe", note: "arm64 + x64 + ia32", }, ], @@ -55,37 +55,37 @@ export const platformDownloads: PlatformDownload[] = [ { id: "linux-x64-appimage", label: "Linux x64 AppImage", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.150.AppImage", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.151.AppImage", note: "x64", }, { id: "linux-x64-deb", label: "Linux x64 DEB", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.150_amd64.deb", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.151_amd64.deb", note: "x64", }, { id: "linux-x64-rpm", label: "Linux x64 RPM", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.150.x86_64.rpm", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.151.x86_64.rpm", note: "x64", }, { id: "linux-arm64-appimage", label: "Linux ARM64 AppImage", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.150-arm64.AppImage", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.151-arm64.AppImage", note: "arm64", }, { id: "linux-arm64-deb", label: "Linux ARM64 DEB", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.150_arm64.deb", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.151_arm64.deb", note: "arm64", }, { id: "linux-arm64-rpm", label: "Linux ARM64 RPM", - href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.150.aarch64.rpm", + href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.151.aarch64.rpm", note: "arm64", }, ], diff --git a/landing/functions/api/download-stats.ts b/landing/functions/api/download-stats.ts new file mode 100644 index 00000000..e196b47e --- /dev/null +++ b/landing/functions/api/download-stats.ts @@ -0,0 +1,34 @@ +import type { CounterDatabase, PagesHandler } from "../types"; +import { DOWNLOAD_COUNT_SEED } from "../../lib/downloads"; + +interface Env { + DOWNLOAD_COUNTER_DB: CounterDatabase; +} + +async function readDownloadCount(db: CounterDatabase) { + const result = await db + .prepare(` + SELECT + COALESCE( + (SELECT value FROM download_counter_settings WHERE key = 'base_downloads'), + ? + ) + COUNT(*) AS downloads + FROM unique_download_windows + `) + .bind(DOWNLOAD_COUNT_SEED) + .first<{ downloads: number }>(); + + return result?.downloads ?? DOWNLOAD_COUNT_SEED; +} + +export const onRequestGet: PagesHandler = async (context) => { + const downloads = await readDownloadCount(context.env.DOWNLOAD_COUNTER_DB); + return Response.json( + { downloads }, + { + headers: { + "Cache-Control": "no-store", + }, + }, + ); +}; diff --git a/landing/functions/api/download.ts b/landing/functions/api/download.ts new file mode 100644 index 00000000..f2b5fcfa --- /dev/null +++ b/landing/functions/api/download.ts @@ -0,0 +1,73 @@ +import { + DOWNLOAD_DEDUP_WINDOW_MS, + getDownloadAsset, +} from "../../lib/downloads"; +import type { CounterDatabase, PagesHandler } from "../types"; + +interface Env { + DOWNLOAD_COUNTER_DB: CounterDatabase; + DOWNLOAD_COUNTER_SALT?: string; +} + +function getClientIp(request: Request) { + const cfConnectingIp = request.headers.get("CF-Connecting-IP")?.trim(); + if (cfConnectingIp) { + return cfConnectingIp; + } + + const forwardedFor = request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim(); + return forwardedFor || null; +} + +function getWindowKey(now = Date.now()) { + return Math.floor(now / DOWNLOAD_DEDUP_WINDOW_MS); +} + +async function sha256Hex(input: string) { + const buffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function countUniqueDownload(db: CounterDatabase, request: Request, assetId: string, salt: string | undefined) { + const clientIp = getClientIp(request); + if (!clientIp) { + return; + } + + const ipHash = await sha256Hex(`${salt ?? ""}:${clientIp}`); + const windowKey = getWindowKey(); + + await db + .prepare(` + INSERT OR IGNORE INTO unique_download_windows ( + ip_hash, + window_key, + first_asset_id, + created_at + ) VALUES (?, ?, ?, datetime('now')) + `) + .bind(ipHash, windowKey, assetId) + .run(); +} + +export const onRequestGet: PagesHandler = async (context) => { + const url = new URL(context.request.url); + const assetId = url.searchParams.get("asset")?.trim(); + if (!assetId) { + return new Response("Missing asset parameter.", { status: 400 }); + } + + const asset = getDownloadAsset(assetId); + if (!asset) { + return new Response("Unknown asset.", { status: 404 }); + } + + await countUniqueDownload( + context.env.DOWNLOAD_COUNTER_DB, + context.request, + asset.id, + context.env.DOWNLOAD_COUNTER_SALT, + ); + + return Response.redirect(asset.href, 302); +}; diff --git a/landing/functions/types.ts b/landing/functions/types.ts new file mode 100644 index 00000000..5b38d263 --- /dev/null +++ b/landing/functions/types.ts @@ -0,0 +1,17 @@ +export interface CounterDbStatement { + bind(...values: unknown[]): { + first(): Promise; + run(): Promise; + }; +} + +export interface CounterDatabase { + prepare(query: string): CounterDbStatement; +} + +export interface PagesHandlerContext { + request: Request; + env: Env; +} + +export type PagesHandler = (context: PagesHandlerContext) => Response | Promise; diff --git a/landing/lib/downloads.ts b/landing/lib/downloads.ts new file mode 100644 index 00000000..aea6f5d2 --- /dev/null +++ b/landing/lib/downloads.ts @@ -0,0 +1,28 @@ +import { platformDownloads, type PlatformDownloadAsset } from "../components/release-links.generated"; + +export const DOWNLOAD_COUNT_SEED = 13674; +export const DOWNLOAD_DEDUP_WINDOW_HOURS = 24; +export const DOWNLOAD_DEDUP_WINDOW_MS = DOWNLOAD_DEDUP_WINDOW_HOURS * 60 * 60 * 1000; + +export type ReleaseDownloadAsset = PlatformDownloadAsset & { + platformId: "macos" | "windows" | "linux"; + platformTitle: string; +}; + +export const downloadAssets: ReleaseDownloadAsset[] = platformDownloads.flatMap((platform) => + platform.assets.map((asset) => ({ + ...asset, + platformId: platform.id, + platformTitle: platform.title, + })), +); + +const downloadAssetMap = new Map(downloadAssets.map((asset) => [asset.id, asset])); + +export function getDownloadAsset(assetId: string) { + return downloadAssetMap.get(assetId) ?? null; +} + +export function getTrackedDownloadHref(assetId: string) { + return `/api/download?asset=${encodeURIComponent(assetId)}`; +} diff --git a/landing/migrations/0001_download_counter.sql b/landing/migrations/0001_download_counter.sql new file mode 100644 index 00000000..fb42ebfe --- /dev/null +++ b/landing/migrations/0001_download_counter.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS download_counter_settings ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL +); + +INSERT OR IGNORE INTO download_counter_settings (key, value) +VALUES ('base_downloads', 13674); + +CREATE TABLE IF NOT EXISTS unique_download_windows ( + ip_hash TEXT NOT NULL, + window_key INTEGER NOT NULL, + first_asset_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (ip_hash, window_key) +); + +CREATE INDEX IF NOT EXISTS idx_unique_download_windows_created_at + ON unique_download_windows(created_at); diff --git a/landing/wrangler.toml b/landing/wrangler.toml index e1b0df56..d71ece3c 100644 --- a/landing/wrangler.toml +++ b/landing/wrangler.toml @@ -1,3 +1,11 @@ name = "agentcompany" compatibility_date = "2026-04-03" pages_build_output_dir = "out" + +[vars] +DOWNLOAD_COUNTER_SALT = "agentcompany-download-counter-v1" + +[[d1_databases]] +binding = "DOWNLOAD_COUNTER_DB" +database_name = "agentcompany-landing-downloads" +database_id = "dfa3f0f3-009b-4436-b519-5b50ae6b1cf9" diff --git a/package.json b/package.json index dfebeaa3..ef5d91c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-company", - "version": "0.4.150", + "version": "0.4.151", "private": true, "type": "module", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", @@ -16,6 +16,7 @@ "build": "electron-vite build && node scripts/build-desktop.mjs", "build:dir": "electron-vite build && node scripts/build-desktop.mjs --dir", "build:all": "node scripts/build-all-platforms.mjs", + "release:cloudflare": "node scripts/release-cloudflare.mjs", "release:local": "node scripts/release-local.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", @@ -38,15 +39,12 @@ "@agentcompany/adapter-utils": "workspace:*", "@phosphor-icons/react": "2.1.10", "@tanstack/react-virtual": "^3.13.23", - "clsx": "2.1.1", "electron-log": "5.4.3", "electron-updater": "6.8.3", "framer-motion": "12.36.0", - "immer": "^11.1.4", "react": "19.2.4", "react-dom": "19.2.4", "recharts": "3.8.1", - "tailwind-merge": "3.5.0", "zod": "4.3.6", "zustand": "^5.0.12" }, diff --git a/packages/adapter-kimi-local/package.json b/packages/adapter-kimi-local/package.json deleted file mode 100644 index 3f016a32..00000000 --- a/packages/adapter-kimi-local/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@agentcompany/adapter-kimi-local", - "version": "0.1.0", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./server": "./src/server/index.ts", - "./ui": "./src/ui/index.ts", - "./cli": "./src/cli/index.ts" - }, - "publishConfig": { - "access": "public", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./server": { - "types": "./dist/server/index.d.ts", - "import": "./dist/server/index.js" - }, - "./ui": { - "types": "./dist/ui/index.d.ts", - "import": "./dist/ui/index.js" - }, - "./cli": { - "types": "./dist/cli/index.d.ts", - "import": "./dist/cli/index.js" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "clean": "rm -rf dist", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@agentcompany/adapter-utils": "workspace:*", - "picocolors": "^1.1.1" - }, - "devDependencies": { - "@types/node": "^24.6.0", - "typescript": "^5.7.3" - } -} diff --git a/packages/adapter-kimi-local/src/cli/format-event.ts b/packages/adapter-kimi-local/src/cli/format-event.ts deleted file mode 100644 index 2e59bdbd..00000000 --- a/packages/adapter-kimi-local/src/cli/format-event.ts +++ /dev/null @@ -1,9 +0,0 @@ -import pc from "picocolors"; - -export function printKimiStreamEvent(line: string, debug = false) { - if (debug) { - console.log(pc.dim(`[kimi] ${line}`)); - return; - } - console.log(line); -} diff --git a/packages/adapter-kimi-local/src/cli/index.ts b/packages/adapter-kimi-local/src/cli/index.ts deleted file mode 100644 index d3b8f24b..00000000 --- a/packages/adapter-kimi-local/src/cli/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { printKimiStreamEvent } from "./format-event.js"; diff --git a/packages/adapter-kimi-local/src/index.ts b/packages/adapter-kimi-local/src/index.ts deleted file mode 100644 index 100c91c5..00000000 --- a/packages/adapter-kimi-local/src/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -export const type = "kimi_local"; -export const label = "Kimi Code (local)"; -export const DEFAULT_KIMI_LOCAL_MODEL = "kimi-for-coding"; - -export const models = [ - { id: DEFAULT_KIMI_LOCAL_MODEL, label: DEFAULT_KIMI_LOCAL_MODEL }, -]; - -export const agentConfigurationDoc = `# kimi_local agent configuration - -Adapter: kimi_local - -Use when: -- You want AgentCompany to invoke the official Kimi Code CLI locally -- You accept generic CLI execution without session resume or structured transcript guarantees - -Core fields: -- cwd (string, optional): absolute working directory fallback for the agent process -- promptTemplate (string, optional): prompt template rendered by AgentCompany before execution -- model (string, optional): informational only, defaults to kimi-for-coding -- command (string, optional): defaults to "kimi" -- extraArgs (string[], optional): additional CLI args -- env (object, optional): KEY=VALUE environment variables - -Operational fields: -- timeoutSec (number, optional): run timeout in seconds -- graceSec (number, optional): graceful termination period in seconds - -Notes: -- Kimi integration currently runs in generic CLI mode. -- Session resume, model discovery, graceful cancel handshakes, structured transcript, and cost attribution are not guaranteed. -- AgentCompany must surface these capability downgrades explicitly in the desktop UI. -`; diff --git a/packages/adapter-kimi-local/src/server/execute.ts b/packages/adapter-kimi-local/src/server/execute.ts deleted file mode 100644 index 15f8d35e..00000000 --- a/packages/adapter-kimi-local/src/server/execute.ts +++ /dev/null @@ -1,86 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import type { AdapterExecutionContext, AdapterExecutionResult, ManagedSkillRef } from "@agentcompany/adapter-utils"; -import { - asNumber, - asString, - asStringArray, - ensureAbsoluteDirectory, - ensureCommandResolvable, - ensurePathInEnv, - runChildProcess, - createManagedSkillSymlinks, -} from "@agentcompany/adapter-utils/server-utils"; -import { normalizeEnv } from "@agentcompany/adapter-utils/test-utils"; -import { DEFAULT_KIMI_LOCAL_MODEL } from "../index.js"; - -function kimiSkillsHome(): string { - return path.join(os.homedir(), ".kimi", "skills"); -} - -async function ensureKimiSkillsInjected( - managedSkills: ManagedSkillRef[], - onLog: AdapterExecutionContext["onLog"], -): Promise { - if (managedSkills.length === 0) return; - await createManagedSkillSymlinks(managedSkills, kimiSkillsHome(), onLog); -} - -export async function execute(ctx: AdapterExecutionContext): Promise { - const { runId, config, context, onLog } = ctx; - const command = asString(config.command, "kimi"); - const cwd = asString(config.cwd, process.cwd()); - const model = asString(config.model, DEFAULT_KIMI_LOCAL_MODEL); - const timeoutSec = asNumber(config.timeoutSec, 0); - const graceSec = asNumber(config.graceSec, 20); - const extraArgs = asStringArray(config.extraArgs); - - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); - const managedSkills: ManagedSkillRef[] = Array.isArray(config.managedSkills) - ? (config.managedSkills as ManagedSkillRef[]).filter( - (s) => typeof s.runtimeName === "string" && typeof s.sourcePath === "string", - ) - : []; - await ensureKimiSkillsInjected(managedSkills, onLog); - - const prompt = typeof context.taskTitle === "string" && context.taskTitle.length > 0 - ? [ - context.taskTitle, - typeof context.taskDescription === "string" && context.taskDescription.length > 0 - ? context.taskDescription - : "", - ].filter(Boolean).join("\n\n") - : [ - `You are agent ${ctx.agent.name}.`, - "Respond with a concise execution summary when complete.", - ].join("\n\n"); - - const env = normalizeEnv(ensurePathInEnv(normalizeEnv(config.env))); - await ensureCommandResolvable(command, cwd, env); - - const result = await runChildProcess(runId, command, extraArgs, { - cwd, - env, - timeoutSec, - graceSec, - stdin: prompt, - onLog, - }); - - const summary = - result.stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .slice(-8) - .join("\n") || null; - - return { - exitCode: result.exitCode, - signal: result.signal, - timedOut: result.timedOut, - summary, - model, - billingType: "unknown", - }; -} diff --git a/packages/adapter-kimi-local/src/server/index.ts b/packages/adapter-kimi-local/src/server/index.ts deleted file mode 100644 index 04036438..00000000 --- a/packages/adapter-kimi-local/src/server/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { execute } from "./execute.js"; -export { testEnvironment } from "./test.js"; diff --git a/packages/adapter-kimi-local/src/server/test.ts b/packages/adapter-kimi-local/src/server/test.ts deleted file mode 100644 index 1617ae8d..00000000 --- a/packages/adapter-kimi-local/src/server/test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { - AdapterEnvironmentTestContext, - AdapterEnvironmentTestResult, -} from "@agentcompany/adapter-utils"; -import { - asString, - ensureCommandResolvable, - ensurePathInEnv, -} from "@agentcompany/adapter-utils/server-utils"; -import { normalizeEnv } from "@agentcompany/adapter-utils/test-utils"; - -export async function testEnvironment( - ctx: AdapterEnvironmentTestContext, -): Promise { - const command = asString(ctx.config.command, "kimi"); - const cwd = asString(ctx.config.cwd, process.cwd()); - const env = normalizeEnv(ensurePathInEnv(normalizeEnv(ctx.config.env))); - - try { - await ensureCommandResolvable(command, cwd, env); - return { - adapterType: "kimi_local", - status: "pass", - checks: [ - { - code: "kimi_command_detected", - level: "info", - message: `Detected Kimi Code command "${command}".`, - }, - ], - testedAt: new Date().toISOString(), - }; - } catch (error) { - return { - adapterType: "kimi_local", - status: "fail", - checks: [ - { - code: "kimi_command_missing", - level: "error", - message: error instanceof Error ? error.message : "Kimi command is not available.", - hint: "Install Kimi Code with `curl -L code.kimi.com/install.sh | bash` and ensure the CLI is available on PATH.", - }, - ], - testedAt: new Date().toISOString(), - }; - } -} diff --git a/packages/adapter-kimi-local/src/ui/build-config.ts b/packages/adapter-kimi-local/src/ui/build-config.ts deleted file mode 100644 index fac4517d..00000000 --- a/packages/adapter-kimi-local/src/ui/build-config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { CreateConfigValues } from "@agentcompany/adapter-utils"; -import { parseCommaArgs, parseEnvVars, parseEnvBindings } from "@agentcompany/adapter-utils/config-utils"; -import { DEFAULT_KIMI_LOCAL_MODEL } from "../index.js"; - -export function buildKimiLocalConfig(v: CreateConfigValues): Record { - const config: Record = {}; - if (v.cwd) config.cwd = v.cwd; - if (v.promptTemplate) config.promptTemplate = v.promptTemplate; - config.model = v.model || DEFAULT_KIMI_LOCAL_MODEL; - config.timeoutSec = 0; - config.graceSec = 20; - const env = parseEnvBindings(v.envBindings); - const legacy = parseEnvVars(v.envVars); - for (const [key, value] of Object.entries(legacy)) { - if (!Object.prototype.hasOwnProperty.call(env, key)) { - env[key] = { type: "plain", value }; - } - } - if (Object.keys(env).length > 0) config.env = env; - if (v.command) config.command = v.command; - if (v.extraArgs) config.extraArgs = parseCommaArgs(v.extraArgs); - return config; -} diff --git a/packages/adapter-kimi-local/src/ui/index.ts b/packages/adapter-kimi-local/src/ui/index.ts deleted file mode 100644 index 87794b87..00000000 --- a/packages/adapter-kimi-local/src/ui/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { buildKimiLocalConfig } from "./build-config.js"; -export { parseKimiStdoutLine } from "./parse-stdout.js"; diff --git a/packages/adapter-kimi-local/src/ui/parse-stdout.ts b/packages/adapter-kimi-local/src/ui/parse-stdout.ts deleted file mode 100644 index 68f38f79..00000000 --- a/packages/adapter-kimi-local/src/ui/parse-stdout.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { TranscriptEntry } from "@agentcompany/adapter-utils"; - -export function parseKimiStdoutLine(line: string, ts: string): TranscriptEntry[] { - const text = line.trim(); - if (!text) return []; - return [{ kind: "stdout", ts, text: line }]; -} diff --git a/packages/adapter-kimi-local/tests/parse-stdout.test.ts b/packages/adapter-kimi-local/tests/parse-stdout.test.ts deleted file mode 100644 index aa8cd5fe..00000000 --- a/packages/adapter-kimi-local/tests/parse-stdout.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseKimiStdoutLine } from "../src/ui/parse-stdout.js"; - -describe("parseKimiStdoutLine", () => { - it("returns a stdout entry for non-empty lines", () => { - const entries = parseKimiStdoutLine("hello", "2026-03-17T00:00:00.000Z"); - expect(entries).toEqual([ - { kind: "stdout", ts: "2026-03-17T00:00:00.000Z", text: "hello" }, - ]); - }); - - it("drops blank lines", () => { - expect(parseKimiStdoutLine(" ", "2026-03-17T00:00:00.000Z")).toEqual([]); - }); -}); diff --git a/packages/adapter-kimi-local/tsconfig.json b/packages/adapter-kimi-local/tsconfig.json deleted file mode 100644 index 5a24989c..00000000 --- a/packages/adapter-kimi-local/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/packages/adapter-opencode-local/package.json b/packages/adapter-opencode-local/package.json deleted file mode 100644 index c5099059..00000000 --- a/packages/adapter-opencode-local/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@agentcompany/adapter-opencode-local", - "version": "0.3.0", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./server": "./src/server/index.ts", - "./ui": "./src/ui/index.ts", - "./cli": "./src/cli/index.ts" - }, - "publishConfig": { - "access": "public", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./server": { - "types": "./dist/server/index.d.ts", - "import": "./dist/server/index.js" - }, - "./ui": { - "types": "./dist/ui/index.d.ts", - "import": "./dist/ui/index.js" - }, - "./cli": { - "types": "./dist/cli/index.d.ts", - "import": "./dist/cli/index.js" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "files": [ - "dist", - "skills" - ], - "scripts": { - "build": "tsc", - "clean": "rm -rf dist", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@agentcompany/adapter-utils": "workspace:*", - "picocolors": "^1.1.1" - }, - "devDependencies": { - "@types/node": "^24.6.0", - "typescript": "^5.7.3" - } -} diff --git a/packages/adapter-opencode-local/src/cli/format-event.ts b/packages/adapter-opencode-local/src/cli/format-event.ts deleted file mode 100644 index 9cd6f97c..00000000 --- a/packages/adapter-opencode-local/src/cli/format-event.ts +++ /dev/null @@ -1,83 +0,0 @@ -import pc from "picocolors"; -import { asRecord, safeJsonParse, asString, asNumber, errorText } from "@agentcompany/adapter-utils/parse-utils"; - -export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { - const line = raw.trim(); - if (!line) return; - - const parsed = asRecord(safeJsonParse(line)); - if (!parsed) { - console.log(line); - return; - } - - const type = asString(parsed.type); - - if (type === "step_start") { - const sessionId = asString(parsed.sessionID); - console.log(pc.blue(`step started${sessionId ? ` (session: ${sessionId})` : ""}`)); - return; - } - - if (type === "text") { - const part = asRecord(parsed.part); - const text = asString(part?.text).trim(); - if (text) console.log(pc.green(`assistant: ${text}`)); - return; - } - - if (type === "reasoning") { - const part = asRecord(parsed.part); - const text = asString(part?.text).trim(); - if (text) console.log(pc.gray(`thinking: ${text}`)); - return; - } - - if (type === "tool_use") { - const part = asRecord(parsed.part); - const tool = asString(part?.tool, "tool"); - const callID = asString(part?.callID); - const state = asRecord(part?.state); - const status = asString(state?.status); - const isError = status === "error"; - const metadata = asRecord(state?.metadata); - - console.log(pc.yellow(`tool_call: ${tool}${callID ? ` (${callID})` : ""}`)); - - if (status) { - const metaParts = [`status=${status}`]; - if (metadata) { - for (const [key, value] of Object.entries(metadata)) { - if (value !== undefined && value !== null) metaParts.push(`${key}=${value}`); - } - } - console.log((isError ? pc.red : pc.gray)(`tool_result ${metaParts.join(" ")}`)); - } - - const output = (asString(state?.output) || asString(state?.error)).trim(); - if (output) console.log((isError ? pc.red : pc.gray)(output)); - return; - } - - if (type === "step_finish") { - const part = asRecord(parsed.part); - const tokens = asRecord(part?.tokens); - const cache = asRecord(tokens?.cache); - const input = asNumber(tokens?.input, 0); - const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0); - const cached = asNumber(cache?.read, 0); - const cost = asNumber(part?.cost, 0); - const reason = asString(part?.reason, "step"); - console.log(pc.blue(`step finished: reason=${reason}`)); - console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`)); - return; - } - - if (type === "error") { - const message = errorText(parsed.error ?? parsed.message); - if (message) console.log(pc.red(`error: ${message}`)); - return; - } - - console.log(line); -} diff --git a/packages/adapter-opencode-local/src/cli/index.ts b/packages/adapter-opencode-local/src/cli/index.ts deleted file mode 100644 index 93c29b69..00000000 --- a/packages/adapter-opencode-local/src/cli/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { printOpenCodeStreamEvent } from "./format-event.js"; diff --git a/packages/adapter-opencode-local/src/index.ts b/packages/adapter-opencode-local/src/index.ts deleted file mode 100644 index 1e235c4a..00000000 --- a/packages/adapter-opencode-local/src/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -export const type = "opencode_local"; -export const label = "OpenCode (local)"; - -export const models: Array<{ id: string; label: string }> = []; - -export const agentConfigurationDoc = `# opencode_local agent configuration - -Adapter: opencode_local - -Use when: -- You want AgentCompany to run OpenCode locally as the agent runtime -- You want provider/model routing in OpenCode format (provider/model) -- You want OpenCode session resume across heartbeats via --session - -Don't use when: -- You need webhook-style external invocation (use openclaw_gateway or http) -- You only need one-shot shell commands (use process) -- OpenCode CLI is not installed on the machine - -Core fields: -- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) -- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt -- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5) -- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max) -- promptTemplate (string, optional): run prompt template -- command (string, optional): defaults to "opencode" -- extraArgs (string[], optional): additional CLI args -- env (object, optional): KEY=VALUE environment variables - -Operational fields: -- timeoutSec (number, optional): run timeout in seconds -- graceSec (number, optional): SIGTERM grace period in seconds - -Notes: -- OpenCode supports multiple providers and models. Use \ - \`opencode models\` to list available options in provider/model format. -- AgentCompany requires an explicit \`model\` value for \`opencode_local\` agents. -- Runs are executed with: opencode run --format json ... -- Sessions are resumed with --session when stored session cwd matches current cwd. -`; diff --git a/packages/adapter-opencode-local/src/server/execute.ts b/packages/adapter-opencode-local/src/server/execute.ts deleted file mode 100644 index c765182a..00000000 --- a/packages/adapter-opencode-local/src/server/execute.ts +++ /dev/null @@ -1,378 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import type { AdapterExecutionContext, AdapterExecutionResult, ManagedSkillRef } from "@agentcompany/adapter-utils"; -import { - asString, - asNumber, - asStringArray, - parseObject, - buildAgentCompanyEnv, - redactEnvForLogs, - ensureAbsoluteDirectory, - ensureCommandResolvable, - ensurePathInEnv, - renderTemplate, - runChildProcess, - createManagedSkillSymlinks, -} from "@agentcompany/adapter-utils/server-utils"; -import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils"; -import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; -import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; - -const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), - path.resolve(__moduleDir, "../../../../../skills"), -]; - -function parseModelProvider(model: string | null): string | null { - if (!model) return null; - const trimmed = model.trim(); - if (!trimmed.includes("/")) return null; - return trimmed.slice(0, trimmed.indexOf("/")).trim() || null; -} - -function openCodeSkillsHome(): string { - return path.join(os.homedir(), ".opencode", "skills"); -} - -async function resolveAgentCompanySkillsDir(): Promise { - for (const candidate of SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} - -async function ensureOpenCodeSkillsInjected( - managedSkills: ManagedSkillRef[], - onLog: AdapterExecutionContext["onLog"], -) { - const skillsHome = openCodeSkillsHome(); - - // Use managed skills when available; fall back to static repo scan - if (managedSkills.length > 0) { - await createManagedSkillSymlinks(managedSkills, skillsHome, onLog); - return; - } - - const skillsDir = await resolveAgentCompanySkillsDir(); - if (!skillsDir) return; - - await fs.mkdir(skillsHome, { recursive: true }); - const entries = await fs.readdir(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); - const target = path.join(skillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; - - try { - await fs.symlink(source, target); - await onLog( - "stderr", - `[agent-company] Injected OpenCode skill "${entry.name}" into ${skillsHome}\n`, - ); - } catch (err) { - await onLog( - "stderr", - `[agent-company] Failed to inject OpenCode skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, - ); - } - } -} - -export async function execute(ctx: AdapterExecutionContext): Promise { - const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; - - const promptTemplate = asString( - config.promptTemplate, - "{{context.taskTitle}}\n\n{{context.taskDescription}}", - ); - const command = asString(config.command, "opencode"); - const model = asString(config.model, "").trim(); - const variant = asString(config.variant, "").trim(); - - const workspaceContext = parseObject(context.agentCompanyWorkspace); - const workspaceCwd = asString(workspaceContext.cwd, ""); - const workspaceSource = asString(workspaceContext.source, ""); - const workspaceId = asString(workspaceContext.workspaceId, ""); - const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); - const workspaceRepoRef = asString(workspaceContext.repoRef, ""); - const workspaceHints = Array.isArray(context.agentCompanyWorkspaces) - ? context.agentCompanyWorkspaces.filter( - (value): value is Record => typeof value === "object" && value !== null, - ) - : []; - const configuredCwd = asString(config.cwd, ""); - const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; - const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; - const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); - const managedSkills: ManagedSkillRef[] = Array.isArray(config.managedSkills) - ? (config.managedSkills as ManagedSkillRef[]).filter( - (s) => typeof s.runtimeName === "string" && typeof s.sourcePath === "string", - ) - : []; - await ensureOpenCodeSkillsInjected(managedSkills, onLog); - - const envConfig = parseObject(config.env); - const hasExplicitApiKey = - typeof envConfig.AGENT_COMPANY_API_KEY === "string" && envConfig.AGENT_COMPANY_API_KEY.trim().length > 0; - const env: Record = { ...buildAgentCompanyEnv(agent) }; - env.AGENT_COMPANY_RUN_ID = runId; - const wakeTaskId = - (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || - (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || - null; - const wakeReason = - typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 - ? context.wakeReason.trim() - : null; - const wakeCommentId = - (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || - (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || - null; - const approvalId = - typeof context.approvalId === "string" && context.approvalId.trim().length > 0 - ? context.approvalId.trim() - : null; - const approvalStatus = - typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 - ? context.approvalStatus.trim() - : null; - const linkedIssueIds = Array.isArray(context.issueIds) - ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) - : []; - if (wakeTaskId) env.AGENT_COMPANY_TASK_ID = wakeTaskId; - if (wakeReason) env.AGENT_COMPANY_WAKE_REASON = wakeReason; - if (wakeCommentId) env.AGENT_COMPANY_WAKE_COMMENT_ID = wakeCommentId; - if (approvalId) env.AGENT_COMPANY_APPROVAL_ID = approvalId; - if (approvalStatus) env.AGENT_COMPANY_APPROVAL_STATUS = approvalStatus; - if (linkedIssueIds.length > 0) env.AGENT_COMPANY_LINKED_ISSUE_IDS = linkedIssueIds.join(","); - if (effectiveWorkspaceCwd) env.AGENT_COMPANY_WORKSPACE_CWD = effectiveWorkspaceCwd; - if (workspaceSource) env.AGENT_COMPANY_WORKSPACE_SOURCE = workspaceSource; - if (workspaceId) env.AGENT_COMPANY_WORKSPACE_ID = workspaceId; - if (workspaceRepoUrl) env.AGENT_COMPANY_WORKSPACE_REPO_URL = workspaceRepoUrl; - if (workspaceRepoRef) env.AGENT_COMPANY_WORKSPACE_REPO_REF = workspaceRepoRef; - if (workspaceHints.length > 0) env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints); - - for (const [key, value] of Object.entries(envConfig)) { - if (typeof value === "string") env[key] = value; - } - if (!hasExplicitApiKey && authToken) { - env.AGENT_COMPANY_API_KEY = authToken; - } - const runtimeEnv = Object.fromEntries( - Object.entries(ensurePathInEnv(env)).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ); - await ensureCommandResolvable(command, cwd, runtimeEnv); - - await ensureOpenCodeModelConfiguredAndAvailable({ - model, - command, - cwd, - env: runtimeEnv, - }); - - const timeoutSec = asNumber(config.timeoutSec, 0); - const graceSec = asNumber(config.graceSec, 20); - const extraArgs = (() => { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); - - const runtimeSessionParams = parseObject(runtime.sessionParams); - const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); - const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); - const canResumeSession = - runtimeSessionId.length > 0 && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); - const sessionId = canResumeSession ? runtimeSessionId : null; - if (runtimeSessionId && !canResumeSession) { - await onLog( - "stderr", - `[agent-company] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, - ); - } - - const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); - const resolvedInstructionsFilePath = instructionsFilePath - ? path.resolve(cwd, instructionsFilePath) - : ""; - const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : ""; - let instructionsPrefix = ""; - if (resolvedInstructionsFilePath) { - try { - const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8"); - instructionsPrefix = - `${instructionsContents}\n\n` + - `The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` + - `Resolve any relative file references from ${instructionsDir}.\n\n`; - await onLog( - "stderr", - `[agent-company] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`, - ); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - await onLog( - "stderr", - `[agent-company] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, - ); - } - } - - const commandNotes = (() => { - if (!resolvedInstructionsFilePath) return [] as string[]; - if (instructionsPrefix.length > 0) { - return [ - `Loaded agent instructions from ${resolvedInstructionsFilePath}`, - `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, - ]; - } - return [ - `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, - ]; - })(); - - const renderedPrompt = renderTemplate(promptTemplate, { - agentId: agent.id, - companyId: agent.companyId, - runId, - company: { id: agent.companyId }, - agent, - run: { id: runId, source: "on_demand" }, - context, - }); - const prompt = `${instructionsPrefix}${renderedPrompt}`; - - const buildArgs = (resumeSessionId: string | null) => { - const args = ["run", "--format", "json"]; - if (resumeSessionId) args.push("--session", resumeSessionId); - if (model) args.push("--model", model); - if (variant) args.push("--variant", variant); - if (extraArgs.length > 0) args.push(...extraArgs); - return args; - }; - - const runAttempt = async (resumeSessionId: string | null) => { - const args = buildArgs(resumeSessionId); - if (onMeta) { - await onMeta({ - adapterType: "opencode_local", - command, - cwd, - commandNotes, - commandArgs: [...args, ``], - env: redactEnvForLogs(env), - prompt, - context, - }); - } - - const proc = await runChildProcess(runId, command, args, { - cwd, - env: runtimeEnv, - stdin: prompt, - timeoutSec, - graceSec, - onLog, - }); - return { - proc, - rawStderr: proc.stderr, - parsed: parseOpenCodeJsonl(proc.stdout), - }; - }; - - const toResult = ( - attempt: { - proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; - rawStderr: string; - parsed: ReturnType; - }, - clearSessionOnMissingSession = false, - ): AdapterExecutionResult => { - if (attempt.proc.timedOut) { - return { - exitCode: attempt.proc.exitCode, - signal: attempt.proc.signal, - timedOut: true, - errorMessage: `Timed out after ${timeoutSec}s`, - clearSession: clearSessionOnMissingSession, - }; - } - - const resolvedSessionId = - attempt.parsed.sessionId ?? - (clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null); - const resolvedSessionParams = resolvedSessionId - ? ({ - sessionId: resolvedSessionId, - cwd, - ...(workspaceId ? { workspaceId } : {}), - ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), - ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), - } as Record) - : null; - - const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; - const stderrLine = firstNonEmptyLine(attempt.proc.stderr); - const rawExitCode = attempt.proc.exitCode; - const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode; - const fallbackErrorMessage = - parsedError || - stderrLine || - `OpenCode exited with code ${synthesizedExitCode ?? -1}`; - const modelId = model || null; - - return { - exitCode: synthesizedExitCode, - signal: attempt.proc.signal, - timedOut: false, - errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage, - usage: { - inputTokens: attempt.parsed.usage.inputTokens, - outputTokens: attempt.parsed.usage.outputTokens, - cachedInputTokens: attempt.parsed.usage.cachedInputTokens, - }, - sessionId: resolvedSessionId, - sessionParams: resolvedSessionParams, - sessionDisplayId: resolvedSessionId, - provider: parseModelProvider(modelId), - model: modelId, - billingType: "unknown", - costUsd: attempt.parsed.costUsd, - resultJson: { - stdout: attempt.proc.stdout, - stderr: attempt.proc.stderr, - }, - summary: attempt.parsed.summary, - clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId), - }; - }; - - const initial = await runAttempt(sessionId); - const initialFailed = - !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage)); - if ( - sessionId && - initialFailed && - isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr) - ) { - await onLog( - "stderr", - `[agent-company] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`, - ); - const retry = await runAttempt(null); - return toResult(retry, true); - } - - return toResult(initial); -} diff --git a/packages/adapter-opencode-local/src/server/index.ts b/packages/adapter-opencode-local/src/server/index.ts deleted file mode 100644 index 9865584d..00000000 --- a/packages/adapter-opencode-local/src/server/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createStandardSessionCodec } from "@agentcompany/adapter-utils/session-codec"; - -export const sessionCodec = createStandardSessionCodec({ extraSessionIdKeys: ["sessionID"] }); - -export { execute } from "./execute.js"; -export { testEnvironment } from "./test.js"; -export { - listOpenCodeModels, - discoverOpenCodeModels, - ensureOpenCodeModelConfiguredAndAvailable, - resetOpenCodeModelsCacheForTests, -} from "./models.js"; -export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; diff --git a/packages/adapter-opencode-local/src/server/models.test.ts b/packages/adapter-opencode-local/src/server/models.test.ts deleted file mode 100644 index 221d694b..00000000 --- a/packages/adapter-opencode-local/src/server/models.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { - ensureOpenCodeModelConfiguredAndAvailable, - listOpenCodeModels, - resetOpenCodeModelsCacheForTests, -} from "./models.js"; - -describe("openCode models", () => { - afterEach(() => { - delete process.env.AGENT_COMPANY_OPENCODE_COMMAND; - resetOpenCodeModelsCacheForTests(); - }); - - it("returns an empty list when discovery command is unavailable", async () => { - process.env.AGENT_COMPANY_OPENCODE_COMMAND = "__agentcompany_missing_opencode_command__"; - await expect(listOpenCodeModels()).resolves.toEqual([]); - }); - - it("rejects when model is missing", async () => { - await expect( - ensureOpenCodeModelConfiguredAndAvailable({ model: "" }), - ).rejects.toThrow("OpenCode requires `adapterConfig.model`"); - }); - - it("rejects when discovery cannot run for configured model", async () => { - process.env.AGENT_COMPANY_OPENCODE_COMMAND = "__agentcompany_missing_opencode_command__"; - await expect( - ensureOpenCodeModelConfiguredAndAvailable({ - model: "openai/gpt-5", - }), - ).rejects.toThrow("Failed to start command"); - }); -}); diff --git a/packages/adapter-opencode-local/src/server/models.ts b/packages/adapter-opencode-local/src/server/models.ts deleted file mode 100644 index 7c5c3eca..00000000 --- a/packages/adapter-opencode-local/src/server/models.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { createHash } from "node:crypto"; -import type { AdapterModel } from "@agentcompany/adapter-utils"; -import { - asString, - ensurePathInEnv, - runChildProcess, -} from "@agentcompany/adapter-utils/server-utils"; -import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils"; - -const MODELS_CACHE_TTL_MS = 60_000; - -function resolveOpenCodeCommand(input: unknown): string { - const envOverride = - typeof process.env.AGENT_COMPANY_OPENCODE_COMMAND === "string" && - process.env.AGENT_COMPANY_OPENCODE_COMMAND.trim().length > 0 - ? process.env.AGENT_COMPANY_OPENCODE_COMMAND.trim() - : "opencode"; - return asString(input, envOverride); -} - -const discoveryCache = new Map(); -const VOLATILE_ENV_KEY_PREFIXES = ["AGENT_COMPANY_", "npm_", "NPM_"] as const; -const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]); - -function dedupeModels(models: AdapterModel[]): AdapterModel[] { - const seen = new Set(); - const deduped: AdapterModel[] = []; - for (const model of models) { - const id = model.id.trim(); - if (!id || seen.has(id)) continue; - seen.add(id); - deduped.push({ id, label: model.label.trim() || id }); - } - return deduped; -} - -function sortModels(models: AdapterModel[]): AdapterModel[] { - return [...models].sort((a, b) => - a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }), - ); -} - -function parseModelsOutput(stdout: string): AdapterModel[] { - const parsed: AdapterModel[] = []; - for (const raw of stdout.split(/\r?\n/)) { - const line = raw.trim(); - if (!line) continue; - const firstToken = line.split(/\s+/)[0]?.trim() ?? ""; - if (!firstToken.includes("/")) continue; - const provider = firstToken.slice(0, firstToken.indexOf("/")).trim(); - const model = firstToken.slice(firstToken.indexOf("/") + 1).trim(); - if (!provider || !model) continue; - parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` }); - } - return dedupeModels(parsed); -} - -function normalizeEnv(input: unknown): Record { - const envInput = typeof input === "object" && input !== null && !Array.isArray(input) - ? (input as Record) - : {}; - const env: Record = {}; - for (const [key, value] of Object.entries(envInput)) { - if (typeof value === "string") env[key] = value; - } - return env; -} - -function isVolatileEnvKey(key: string): boolean { - if (VOLATILE_ENV_KEY_EXACT.has(key)) return true; - return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)); -} - -function hashValue(value: string): string { - return createHash("sha256").update(value).digest("hex"); -} - -function discoveryCacheKey(command: string, cwd: string, env: Record) { - const envKey = Object.entries(env) - .filter(([key]) => !isVolatileEnvKey(key)) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => `${key}=${hashValue(value)}`) - .join("\n"); - return `${command}\n${cwd}\n${envKey}`; -} - -function pruneExpiredDiscoveryCache(now: number) { - for (const [key, value] of discoveryCache.entries()) { - if (value.expiresAt <= now) discoveryCache.delete(key); - } -} - -export async function discoverOpenCodeModels(input: { - command?: unknown; - cwd?: unknown; - env?: unknown; -} = {}): Promise { - const command = resolveOpenCodeCommand(input.command); - const cwd = asString(input.cwd, process.cwd()); - const env = normalizeEnv(input.env); - const runtimeEnv = normalizeEnv(ensurePathInEnv(env)); - - const result = await runChildProcess( - `opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`, - command, - ["models"], - { - cwd, - env: runtimeEnv, - timeoutSec: 20, - graceSec: 3, - onLog: async () => {}, - }, - ); - - if (result.timedOut) { - throw new Error("`opencode models` timed out."); - } - if ((result.exitCode ?? 1) !== 0) { - const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout); - throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed."); - } - - return sortModels(parseModelsOutput(result.stdout)); -} - -export async function discoverOpenCodeModelsCached(input: { - command?: unknown; - cwd?: unknown; - env?: unknown; -} = {}): Promise { - const command = resolveOpenCodeCommand(input.command); - const cwd = asString(input.cwd, process.cwd()); - const env = normalizeEnv(input.env); - const key = discoveryCacheKey(command, cwd, env); - const now = Date.now(); - pruneExpiredDiscoveryCache(now); - const cached = discoveryCache.get(key); - if (cached && cached.expiresAt > now) return cached.models; - - const models = await discoverOpenCodeModels({ command, cwd, env }); - discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models }); - return models; -} - -export async function ensureOpenCodeModelConfiguredAndAvailable(input: { - model?: unknown; - command?: unknown; - cwd?: unknown; - env?: unknown; -}): Promise { - const model = asString(input.model, "").trim(); - if (!model) { - throw new Error("OpenCode requires `adapterConfig.model` in provider/model format."); - } - - const models = await discoverOpenCodeModelsCached({ - command: input.command, - cwd: input.cwd, - env: input.env, - }); - - if (models.length === 0) { - throw new Error("OpenCode returned no models. Run `opencode models` and verify provider auth."); - } - - if (!models.some((entry) => entry.id === model)) { - const sample = models.slice(0, 12).map((entry) => entry.id).join(", "); - throw new Error( - `Configured OpenCode model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`, - ); - } - - return models; -} - -export async function listOpenCodeModels(): Promise { - try { - return await discoverOpenCodeModelsCached(); - } catch { - return []; - } -} - -export function resetOpenCodeModelsCacheForTests() { - discoveryCache.clear(); -} diff --git a/packages/adapter-opencode-local/src/server/parse.test.ts b/packages/adapter-opencode-local/src/server/parse.test.ts deleted file mode 100644 index 5f4a3a36..00000000 --- a/packages/adapter-opencode-local/src/server/parse.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; - -describe("parseOpenCodeJsonl", () => { - it("parses assistant text, usage, cost, and errors", () => { - const stdout = [ - JSON.stringify({ - type: "text", - sessionID: "session_123", - part: { text: "Hello from OpenCode" }, - }), - JSON.stringify({ - type: "step_finish", - sessionID: "session_123", - part: { - reason: "done", - cost: 0.0025, - tokens: { - input: 120, - output: 40, - reasoning: 10, - cache: { read: 20, write: 0 }, - }, - }, - }), - JSON.stringify({ - type: "error", - sessionID: "session_123", - error: { message: "model unavailable" }, - }), - ].join("\n"); - - const parsed = parseOpenCodeJsonl(stdout); - expect(parsed.sessionId).toBe("session_123"); - expect(parsed.summary).toBe("Hello from OpenCode"); - expect(parsed.usage).toEqual({ - inputTokens: 120, - cachedInputTokens: 20, - outputTokens: 50, - }); - expect(parsed.costUsd).toBeCloseTo(0.0025, 6); - expect(parsed.errorMessage).toContain("model unavailable"); - }); - - it("detects unknown session errors", () => { - expect(isOpenCodeUnknownSessionError("Session not found: s_123", "")).toBe(true); - expect(isOpenCodeUnknownSessionError("", "unknown session id")).toBe(true); - expect(isOpenCodeUnknownSessionError("all good", "")).toBe(false); - }); -}); diff --git a/packages/adapter-opencode-local/src/server/parse.ts b/packages/adapter-opencode-local/src/server/parse.ts deleted file mode 100644 index a2d85708..00000000 --- a/packages/adapter-opencode-local/src/server/parse.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { asNumber, asString, parseJson, parseObject } from "@agentcompany/adapter-utils/server-utils"; - -function errorText(value: unknown): string { - if (typeof value === "string") return value; - const rec = parseObject(value); - const message = asString(rec.message, "").trim(); - if (message) return message; - const data = parseObject(rec.data); - const nestedMessage = asString(data.message, "").trim(); - if (nestedMessage) return nestedMessage; - const name = asString(rec.name, "").trim(); - if (name) return name; - const code = asString(rec.code, "").trim(); - if (code) return code; - try { - return JSON.stringify(rec); - } catch { - return ""; - } -} - -export function parseOpenCodeJsonl(stdout: string) { - let sessionId: string | null = null; - const messages: string[] = []; - const errors: string[] = []; - const usage = { - inputTokens: 0, - cachedInputTokens: 0, - outputTokens: 0, - }; - let costUsd = 0; - - for (const rawLine of stdout.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line) continue; - - const event = parseJson(line); - if (!event) continue; - - const currentSessionId = asString(event.sessionID, "").trim(); - if (currentSessionId) sessionId = currentSessionId; - - const type = asString(event.type, ""); - - if (type === "text") { - const part = parseObject(event.part); - const text = asString(part.text, "").trim(); - if (text) messages.push(text); - continue; - } - - if (type === "step_finish") { - const part = parseObject(event.part); - const tokens = parseObject(part.tokens); - const cache = parseObject(tokens.cache); - usage.inputTokens += asNumber(tokens.input, 0); - usage.cachedInputTokens += asNumber(cache.read, 0); - usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0); - costUsd += asNumber(part.cost, 0); - continue; - } - - if (type === "tool_use") { - const part = parseObject(event.part); - const state = parseObject(part.state); - if (asString(state.status, "") === "error") { - const text = asString(state.error, "").trim(); - if (text) errors.push(text); - } - continue; - } - - if (type === "error") { - const text = errorText(event.error ?? event.message).trim(); - if (text) errors.push(text); - continue; - } - } - - return { - sessionId, - summary: messages.join("\n\n").trim(), - usage, - costUsd, - errorMessage: errors.length > 0 ? errors.join("\n") : null, - }; -} - -export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): boolean { - const haystack = `${stdout}\n${stderr}` - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .join("\n"); - - return /unknown\s+session|session\b.*\bnot\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test( - haystack, - ); -} diff --git a/packages/adapter-opencode-local/src/server/test.ts b/packages/adapter-opencode-local/src/server/test.ts deleted file mode 100644 index 1b31f515..00000000 --- a/packages/adapter-opencode-local/src/server/test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import type { - AdapterEnvironmentCheck, - AdapterEnvironmentTestContext, - AdapterEnvironmentTestResult, -} from "@agentcompany/adapter-utils"; -import { - asString, - asStringArray, - parseObject, - ensureAbsoluteDirectory, - ensureCommandResolvable, - ensurePathInEnv, - runChildProcess, -} from "@agentcompany/adapter-utils/server-utils"; -import { summarizeStatus, summarizeProbeDetail, normalizeEnv } from "@agentcompany/adapter-utils/test-utils"; -import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; -import { parseOpenCodeJsonl } from "./parse.js"; - -const OPENCODE_AUTH_REQUIRED_RE = - /(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|opencode\s+auth\s+login|free\s+usage\s+exceeded)/i; - -export async function testEnvironment( - ctx: AdapterEnvironmentTestContext, -): Promise { - // Wrap the entire test in a safety net — if anything crashes, - // still return a usable result instead of leaving status at "detected". - try { - return await _testEnvironmentInner(ctx); - } catch { - return { - adapterType: ctx.adapterType, - status: "pass", - checks: [{ code: "opencode_test_error", level: "info", message: "OpenCode environment test could not complete." }], - testedAt: new Date().toISOString(), - }; - } -} - -async function _testEnvironmentInner( - ctx: AdapterEnvironmentTestContext, -): Promise { - const checks: AdapterEnvironmentCheck[] = []; - const config = parseObject(ctx.config); - const command = asString(config.command, "opencode"); - const cwd = asString(config.cwd, process.cwd()); - - try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: false }); - checks.push({ - code: "opencode_cwd_valid", - level: "info", - message: `Working directory is valid: ${cwd}`, - }); - } catch (err) { - checks.push({ - code: "opencode_cwd_invalid", - level: "error", - message: err instanceof Error ? err.message : "Invalid working directory", - detail: cwd, - }); - } - - const envConfig = parseObject(config.env); - const env: Record = {}; - for (const [key, value] of Object.entries(envConfig)) { - if (typeof value === "string") env[key] = value; - } - - const openaiKeyOverride = "OPENAI_API_KEY" in envConfig ? asString(envConfig.OPENAI_API_KEY, "") : null; - if (openaiKeyOverride !== null && openaiKeyOverride.trim() === "") { - checks.push({ - code: "opencode_openai_api_key_missing", - level: "warn", - message: "OPENAI_API_KEY override is empty.", - hint: "The OPENAI_API_KEY override is empty. Set a valid key or remove the override.", - }); - } - - const runtimeEnv = normalizeEnv(ensurePathInEnv(env)); - - const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); - if (cwdInvalid) { - checks.push({ - code: "opencode_command_skipped", - level: "warn", - message: "Skipped command check because working directory validation failed.", - detail: command, - }); - } else { - try { - await ensureCommandResolvable(command, cwd, runtimeEnv); - checks.push({ - code: "opencode_command_resolvable", - level: "info", - message: `Command is executable: ${command}`, - }); - } catch (err) { - checks.push({ - code: "opencode_command_unresolvable", - level: "error", - message: err instanceof Error ? err.message : "Command is not executable", - detail: command, - }); - } - } - - const canRunProbe = - checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); - - let modelValidationPassed = false; - const configuredModel = asString(config.model, "").trim(); - - if (canRunProbe && configuredModel) { - try { - const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); - if (discovered.length > 0) { - checks.push({ - code: "opencode_models_discovered", - level: "info", - message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, - }); - } else { - checks.push({ - code: "opencode_models_empty", - level: "error", - message: "OpenCode returned no models.", - hint: "Run `opencode models` and verify provider authentication.", - }); - } - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - if (/ProviderModelNotFoundError/i.test(errMsg)) { - checks.push({ - code: "opencode_hello_probe_model_unavailable", - level: "warn", - message: "The configured model was not found by the provider.", - detail: errMsg, - hint: "Run `opencode models` and choose an available provider/model ID.", - }); - } else { - checks.push({ - code: "opencode_models_discovery_failed", - level: "error", - message: errMsg || "OpenCode model discovery failed.", - hint: "Run `opencode models` manually to verify provider auth and config.", - }); - } - } - } else if (canRunProbe && !configuredModel) { - try { - const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); - if (discovered.length > 0) { - checks.push({ - code: "opencode_models_discovered", - level: "info", - message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, - }); - } - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - if (/ProviderModelNotFoundError/i.test(errMsg)) { - checks.push({ - code: "opencode_hello_probe_model_unavailable", - level: "warn", - message: "The configured model was not found by the provider.", - detail: errMsg, - hint: "Run `opencode models` and choose an available provider/model ID.", - }); - } else { - checks.push({ - code: "opencode_models_discovery_failed", - level: "warn", - message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).", - hint: "Run `opencode models` manually to verify provider auth and config.", - }); - } - } - } - - if (!configuredModel) { - checks.push({ - code: "opencode_model_missing", - level: "warn", - message: "OpenCode requires a configured provider/model ID before runs can start.", - hint: "Run `opencode models`, choose a provider/model, and save it in the connector settings.", - }); - } else if (configuredModel && canRunProbe) { - try { - await ensureOpenCodeModelConfiguredAndAvailable({ - model: configuredModel, - command, - cwd, - env: runtimeEnv, - }); - checks.push({ - code: "opencode_model_configured", - level: "info", - message: `Configured model: ${configuredModel}`, - }); - modelValidationPassed = true; - } catch (err) { - checks.push({ - code: "opencode_model_invalid", - level: "error", - message: err instanceof Error ? err.message : "Configured model is unavailable.", - hint: "Run `opencode models` and choose a currently available provider/model ID.", - }); - } - } - - if (canRunProbe && modelValidationPassed) { - const extraArgs = (() => { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); - const variant = asString(config.variant, "").trim(); - const probeModel = configuredModel; - - const args = ["run", "--format", "json"]; - args.push("--model", probeModel); - if (variant) args.push("--variant", variant); - if (extraArgs.length > 0) args.push(...extraArgs); - - try { - const probe = await runChildProcess( - `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, - command, - args, - { - cwd, - env: runtimeEnv, - timeoutSec: 60, - graceSec: 5, - stdin: "Respond with hello.", - onLog: async () => {}, - }, - ); - - const parsed = parseOpenCodeJsonl(probe.stdout); - const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); - const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); - - if (probe.timedOut) { - checks.push({ - code: "opencode_hello_probe_timed_out", - level: "warn", - message: "OpenCode hello probe timed out.", - hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.", - }); - } else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) { - const summary = parsed.summary.trim(); - const hasHello = /\bhello\b/i.test(summary); - checks.push({ - code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output", - level: hasHello ? "info" : "warn", - message: hasHello - ? "OpenCode hello probe succeeded." - : "OpenCode probe ran but did not return `hello` as expected.", - ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), - ...(hasHello - ? {} - : { - hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", - }), - }); - } else if (/ProviderModelNotFoundError/i.test(authEvidence)) { - checks.push({ - code: "opencode_hello_probe_model_unavailable", - level: "warn", - message: "The configured model was not found by the provider.", - ...(detail ? { detail } : {}), - hint: "Run `opencode models` and choose an available provider/model ID.", - }); - } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { - checks.push({ - code: "opencode_hello_probe_auth_required", - level: "warn", - message: "OpenCode is installed, but provider authentication is not ready.", - ...(detail ? { detail } : {}), - hint: "Run `opencode auth login` or set provider credentials, then retry the probe.", - }); - } else { - checks.push({ - code: "opencode_hello_probe_failed", - level: "error", - message: "OpenCode hello probe failed.", - ...(detail ? { detail } : {}), - hint: "Run `opencode run --format json` manually in this working directory to debug.", - }); - } - } catch (err) { - checks.push({ - code: "opencode_hello_probe_failed", - level: "error", - message: "OpenCode hello probe failed.", - detail: err instanceof Error ? err.message : String(err), - hint: "Run `opencode run --format json` manually in this working directory to debug.", - }); - } - } - - return { - adapterType: ctx.adapterType, - status: summarizeStatus(checks), - checks, - testedAt: new Date().toISOString(), - }; -} diff --git a/packages/adapter-opencode-local/src/ui/build-config.ts b/packages/adapter-opencode-local/src/ui/build-config.ts deleted file mode 100644 index cd76e9af..00000000 --- a/packages/adapter-opencode-local/src/ui/build-config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { CreateConfigValues } from "@agentcompany/adapter-utils"; -import { parseCommaArgs, parseEnvVars, parseEnvBindings } from "@agentcompany/adapter-utils/config-utils"; - -export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record { - const ac: Record = {}; - if (v.cwd) ac.cwd = v.cwd; - if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; - if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; - if (v.model) ac.model = v.model; - if (v.thinkingEffort) ac.variant = v.thinkingEffort; - // OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0) - // and rely on graceSec for termination handling when a timeout is configured elsewhere. - ac.timeoutSec = 0; - ac.graceSec = 20; - const env = parseEnvBindings(v.envBindings); - const legacy = parseEnvVars(v.envVars); - for (const [key, value] of Object.entries(legacy)) { - if (!Object.prototype.hasOwnProperty.call(env, key)) { - env[key] = { type: "plain", value }; - } - } - if (Object.keys(env).length > 0) ac.env = env; - if (v.command) ac.command = v.command; - if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); - return ac; -} diff --git a/packages/adapter-opencode-local/src/ui/index.ts b/packages/adapter-opencode-local/src/ui/index.ts deleted file mode 100644 index a06f826d..00000000 --- a/packages/adapter-opencode-local/src/ui/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { parseOpenCodeStdoutLine } from "./parse-stdout.js"; -export { buildOpenCodeLocalConfig } from "./build-config.js"; diff --git a/packages/adapter-opencode-local/src/ui/parse-stdout.ts b/packages/adapter-opencode-local/src/ui/parse-stdout.ts deleted file mode 100644 index 71973340..00000000 --- a/packages/adapter-opencode-local/src/ui/parse-stdout.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { TranscriptEntry } from "@agentcompany/adapter-utils"; -import { asRecord, safeJsonParse } from "@agentcompany/adapter-utils/parse-utils"; - -function asString(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function asNumber(value: unknown, fallback = 0): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -function errorText(value: unknown): string { - if (typeof value === "string") return value; - const rec = asRecord(value); - if (!rec) return ""; - const data = asRecord(rec.data); - const msg = - asString(rec.message) || - asString(data?.message) || - asString(rec.name) || - ""; - if (msg) return msg; - try { - return JSON.stringify(rec); - } catch { - return ""; - } -} - -function parseToolUse(parsed: Record, ts: string): TranscriptEntry[] { - const part = asRecord(parsed.part); - if (!part) return [{ kind: "system", ts, text: "tool event" }]; - - const toolName = asString(part.tool, "tool"); - const state = asRecord(part.state); - const input = state?.input ?? {}; - const callEntry: TranscriptEntry = { - kind: "tool_call", - ts, - name: toolName, - toolUseId: asString(part.callID) || asString(part.id) || undefined, - input, - }; - - const status = asString(state?.status); - if (status !== "completed" && status !== "error") return [callEntry]; - - const rawOutput = - asString(state?.output) || - asString(state?.error) || - asString(part.title) || - `${toolName} ${status}`; - - const metadata = asRecord(state?.metadata); - const headerParts: string[] = [`status: ${status}`]; - if (metadata) { - for (const [key, value] of Object.entries(metadata)) { - if (value !== undefined && value !== null) headerParts.push(`${key}: ${value}`); - } - } - const content = `${headerParts.join("\n")}\n\n${rawOutput}`.trim(); - - return [ - callEntry, - { - kind: "tool_result", - ts, - toolUseId: asString(part.callID) || asString(part.id, toolName), - content, - isError: status === "error", - }, - ]; -} - -export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEntry[] { - const parsed = asRecord(safeJsonParse(line)); - if (!parsed) { - return [{ kind: "stdout", ts, text: line }]; - } - - const type = asString(parsed.type); - - if (type === "text") { - const part = asRecord(parsed.part); - const text = asString(part?.text).trim(); - if (!text) return []; - return [{ kind: "assistant", ts, text }]; - } - - if (type === "reasoning") { - const part = asRecord(parsed.part); - const text = asString(part?.text).trim(); - if (!text) return []; - return [{ kind: "thinking", ts, text }]; - } - - if (type === "tool_use") { - return parseToolUse(parsed, ts); - } - - if (type === "step_start") { - const sessionId = asString(parsed.sessionID); - return [ - { - kind: "system", - ts, - text: `step started${sessionId ? ` (${sessionId})` : ""}`, - }, - ]; - } - - if (type === "step_finish") { - const part = asRecord(parsed.part); - const tokens = asRecord(part?.tokens); - const cache = asRecord(tokens?.cache); - const reason = asString(part?.reason, "step"); - const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0); - return [ - { - kind: "result", - ts, - text: reason, - inputTokens: asNumber(tokens?.input, 0), - outputTokens: output, - cachedTokens: asNumber(cache?.read, 0), - costUsd: asNumber(part?.cost, 0), - subtype: reason, - isError: false, - errors: [], - }, - ]; - } - - if (type === "error") { - const text = errorText(parsed.error ?? parsed.message); - return [{ kind: "stderr", ts, text: text || line }]; - } - - return [{ kind: "stdout", ts, text: line }]; -} diff --git a/packages/adapter-opencode-local/tsconfig.json b/packages/adapter-opencode-local/tsconfig.json deleted file mode 100644 index 5a24989c..00000000 --- a/packages/adapter-opencode-local/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/packages/adapter-opencode-local/vitest.config.ts b/packages/adapter-opencode-local/vitest.config.ts deleted file mode 100644 index f624398e..00000000 --- a/packages/adapter-opencode-local/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c3a7769..e1526861 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.23 version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - clsx: - specifier: 2.1.1 - version: 2.1.1 electron-log: specifier: 5.4.3 version: 5.4.3 @@ -38,9 +35,6 @@ importers: framer-motion: specifier: 12.36.0 version: 12.36.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - immer: - specifier: ^11.1.4 - version: 11.1.4 react: specifier: 19.2.4 version: 19.2.4 @@ -50,9 +44,6 @@ importers: recharts: specifier: 3.8.1 version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1) - tailwind-merge: - specifier: 3.5.0 - version: 3.5.0 zod: specifier: 4.3.6 version: 4.3.6 @@ -169,38 +160,6 @@ importers: specifier: ^5.7.3 version: 5.9.3 - packages/adapter-kimi-local: - dependencies: - '@agentcompany/adapter-utils': - specifier: workspace:* - version: link:../adapter-utils - picocolors: - specifier: ^1.1.1 - version: 1.1.1 - devDependencies: - '@types/node': - specifier: ^24.6.0 - version: 24.12.0 - typescript: - specifier: ^5.7.3 - version: 5.9.3 - - packages/adapter-opencode-local: - dependencies: - '@agentcompany/adapter-utils': - specifier: workspace:* - version: link:../adapter-utils - picocolors: - specifier: ^1.1.1 - version: 1.1.1 - devDependencies: - '@types/node': - specifier: ^24.6.0 - version: 24.12.0 - typescript: - specifier: ^5.7.3 - version: 5.9.3 - packages/adapter-utils: devDependencies: '@types/node': @@ -2811,9 +2770,6 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - tailwind-merge@3.5.0: - resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss@4.2.1: resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} @@ -5798,8 +5754,6 @@ snapshots: dependencies: has-flag: 4.0.0 - tailwind-merge@3.5.0: {} - tailwindcss@4.2.1: {} tapable@2.3.0: {} diff --git a/scripts/release-cloudflare.mjs b/scripts/release-cloudflare.mjs new file mode 100644 index 00000000..6ffaed6a --- /dev/null +++ b/scripts/release-cloudflare.mjs @@ -0,0 +1,550 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile, mkdir, cp, readdir } from "node:fs/promises"; +import os from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { prepareR2ReleaseHost, uploadReleaseFiles } from "./release-local/r2.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, ".."); +const packageJsonPath = join(rootDir, "package.json"); +const landingDir = join(rootDir, "landing"); +const localReleaseDir = join(rootDir, "local-release"); +const remoteWorkflowFile = ".github/workflows/remote-release-platforms.yml"; +const remoteWorkflowName = "remote-release-platforms.yml"; +const pagesProjectName = "agentcompany"; +const pagesBranch = "main"; +const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/; +const syncExcludes = [ + ".git", + ".github/workflows/", + "node_modules/", + ".pnpm-store/", + "build/", + "local-release/", + "out/", + ".omx/", + ".omc/", + ".claude/", + "landing/.next/", + "landing/out/", + "landing/node_modules/", + ".DS_Store", + ".env.release.local", + "CertificateSigningRequest.certSigningRequest", + "Certificates.p12", + "developerID_application.cer", +]; + +function parseArgs(argv) { + const options = { + version: null, + skipVerify: false, + deleteRemoteBranch: false, + keepTransportRelease: false, + }; + + for (const arg of argv) { + if (arg.startsWith("--version=")) { + options.version = arg.slice("--version=".length).trim(); + continue; + } + if (arg === "--skip-verify") { + options.skipVerify = true; + continue; + } + if (arg === "--delete-remote-branch") { + options.deleteRemoteBranch = true; + continue; + } + if (arg === "--keep-transport-release") { + options.keepTransportRelease = true; + continue; + } + if (arg === "--help") { + printHelp(); + process.exit(0); + } + throw new Error(`Unknown argument: ${arg}`); + } + + return options; +} + +function printHelp() { + console.log(`Usage: node scripts/release-cloudflare.mjs [options] + +Options: + --version=x.y.z Explicit version. Defaults to current patch + 1. + --skip-verify Skip local pnpm verify before packaging. + --delete-remote-branch Delete the GitHub snapshot branch after success. + --keep-transport-release Keep the temporary draft GitHub release after success. + --help Show this help. +`); +} + +async function loadEnvFile(filePath) { + if (!existsSync(filePath)) return; + const content = await readFile(filePath, "utf8"); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const separatorIndex = line.indexOf("="); + if (separatorIndex <= 0) continue; + const key = line.slice(0, separatorIndex).trim(); + if (!key || process.env[key]) continue; + const rawValue = line.slice(separatorIndex + 1).trim(); + if (!rawValue) continue; + process.env[key] = rawValue.replace(/^['"]|['"]$/g, ""); + } +} + +function run(command, args, options = {}) { + const { + cwd = rootDir, + env = process.env, + input = null, + capture = false, + } = options; + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env, + stdio: capture ? ["pipe", "pipe", "pipe"] : ["pipe", "inherit", "inherit"], + shell: process.platform === "win32", + }); + + let stdout = ""; + let stderr = ""; + if (capture) { + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + } + + if (input) { + child.stdin.write(input); + } + child.stdin.end(); + + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + reject(new Error(stderr || stdout || `${command} ${args.join(" ")} failed with exit code ${code ?? 1}`)); + }); + }); +} + +async function ghJson(args) { + const { stdout } = await run("gh", args, { capture: true }); + return JSON.parse(stdout); +} + +async function getGitHubHttpAuthHeader() { + const tokenResult = await run("gh", ["auth", "token"], { capture: true }); + const token = tokenResult.stdout.trim(); + if (!token) { + throw new Error("Unable to read a GitHub token from gh auth."); + } + return Buffer.from(`x-access-token:${token}`).toString("base64"); +} + +async function getRepoInfo() { + const info = await ghJson(["repo", "view", "--json", "nameWithOwner,defaultBranchRef"]); + const defaultBranch = info.defaultBranchRef?.name; + if (!info.nameWithOwner || !defaultBranch) { + throw new Error("Unable to resolve GitHub repository name/default branch from gh."); + } + return { + nameWithOwner: info.nameWithOwner, + defaultBranch, + }; +} + +async function assertRemoteWorkflowExists(repo) { + try { + await run("gh", [ + "api", + `repos/${repo.nameWithOwner}/contents/${remoteWorkflowFile}?ref=${repo.defaultBranch}`, + ], { capture: true }); + } catch { + throw new Error( + `Remote workflow ${remoteWorkflowFile} was not found on ${repo.defaultBranch}. ` + + "Commit and push this automation workflow before using scripts/release-cloudflare.mjs.", + ); + } +} + +async function readPackageJson() { + return JSON.parse(await readFile(packageJsonPath, "utf8")); +} + +function bumpPatchVersion(version) { + const match = versionPattern.exec(version); + if (!match) { + throw new Error(`Unsupported package version format: ${version}`); + } + const [, major, minor, patch] = match; + return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`; +} + +async function updatePackageVersion(nextVersion) { + const pkg = await readPackageJson(); + pkg.version = nextVersion; + await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8"); +} + +async function ensureRemoteBuildPrerequisites(repo) { + if (process.platform !== "darwin") { + throw new Error("This release script currently expects to run on macOS for the signed macOS build."); + } + if (!existsSync(join(rootDir, remoteWorkflowFile))) { + throw new Error(`Required workflow file missing locally: ${remoteWorkflowFile}`); + } + await assertRemoteWorkflowExists(repo); + await run("gh", ["auth", "status"], { capture: true }); + await run("npx", ["wrangler", "whoami", "--json"], { capture: true }); +} + +async function resetReleaseStaging() { + await rm(localReleaseDir, { recursive: true, force: true }); + await rm(join(rootDir, "build", "release"), { recursive: true, force: true }); +} + +async function buildMacAndUpload() { + await run("node", ["scripts/release-local.mjs", "--platforms=mac", "--skip-verify"]); +} + +async function createSnapshotBranch(repo, branchName, version) { + const tempDir = await mkdtemp(join(os.tmpdir(), "agentcompany-release-")); + const auth = await getGitHubHttpAuthHeader(); + await run("git", [ + "-c", + `http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth}`, + "fetch", + `https://github.com/${repo.nameWithOwner}.git`, + repo.defaultBranch, + ]); + await run("git", ["worktree", "add", "--detach", tempDir, "FETCH_HEAD"]); + await run("git", ["-C", tempDir, "checkout", "-b", branchName]); + + const rsyncArgs = ["-a", "--delete"]; + for (const pattern of syncExcludes) { + rsyncArgs.push("--exclude", pattern); + } + rsyncArgs.push(`${rootDir}/`, `${tempDir}/`); + await run("rsync", rsyncArgs); + + await run("git", ["-C", tempDir, "add", "-A"]); + const diffStatus = await run("git", ["-C", tempDir, "diff", "--cached", "--quiet"], { capture: true }) + .then(() => false) + .catch(() => true); + + if (!diffStatus) { + throw new Error("Snapshot branch has no staged changes after syncing the workspace."); + } + + const commitMessage = [ + `Package release ${version} from the current workspace snapshot`, + "", + "Constraint: Windows and Linux artifacts are built on native GitHub runners from a temporary snapshot branch", + "Rejected: Build Windows/Linux locally on Apple Silicon | docker/wine packaging is not stable on this host", + "Confidence: medium", + "Scope-risk: moderate", + "Directive: This snapshot branch is transport-only and should be deleted after the release completes", + "Tested: local verify/build stages before snapshot creation", + "Not-tested: remote packaging before workflow execution", + ].join("\n"); + await run("git", ["-C", tempDir, "commit", "-m", commitMessage]); + + await run( + "git", + [ + "-C", + tempDir, + "-c", + `http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth}`, + "push", + `https://github.com/${repo.nameWithOwner}.git`, + branchName, + ], + ); + + return tempDir; +} + +async function waitForWorkflowRun(repo, branchName, timeoutMs) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const runs = await ghJson([ + "run", + "list", + "--repo", + repo.nameWithOwner, + "--workflow", + remoteWorkflowName, + "--branch", + branchName, + "--limit", + "1", + "--json", + "databaseId,status,conclusion,url,displayTitle", + ]); + if (runs.length > 0) { + return runs[0]; + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + throw new Error(`Timed out waiting for workflow ${remoteWorkflowName} on branch ${branchName}.`); +} + +async function dispatchRemoteWorkflow(repo, branchName, releaseTag, releaseTitle) { + await run("gh", [ + "workflow", + "run", + remoteWorkflowName, + "--repo", + repo.nameWithOwner, + "--ref", + branchName, + "-f", + `release_tag=${releaseTag}`, + "-f", + `release_title=${releaseTitle}`, + ]); +} + +async function waitForWorkflowCompletion(repo, runId, timeoutMs) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const runInfo = await ghJson([ + "run", + "view", + String(runId), + "--repo", + repo.nameWithOwner, + "--json", + "status,conclusion,url", + ]); + if (runInfo.status === "completed") { + return runInfo; + } + await new Promise((resolve) => setTimeout(resolve, 15000)); + } + throw new Error(`Timed out waiting for workflow run ${runId} to complete.`); +} + +async function downloadRemoteAssets(repo, releaseTag) { + const downloadRoot = await mkdtemp(join(os.tmpdir(), "agentcompany-transport-")); + await run("gh", [ + "release", + "download", + releaseTag, + "--repo", + repo.nameWithOwner, + "-D", + downloadRoot, + ]); + return downloadRoot; +} + +async function stageRemoteAssets(downloadRoot) { + const winDir = join(localReleaseDir, "win"); + const linuxDir = join(localReleaseDir, "linux"); + await rm(winDir, { recursive: true, force: true }); + await rm(linuxDir, { recursive: true, force: true }); + await mkdir(winDir, { recursive: true }); + await mkdir(linuxDir, { recursive: true }); + + const files = await readdir(downloadRoot); + for (const fileName of files) { + const sourcePath = join(downloadRoot, fileName); + if (fileName.endsWith(".exe") || fileName.endsWith(".blockmap") || fileName === "latest.yml") { + await cp(sourcePath, join(winDir, fileName)); + continue; + } + if ( + fileName.endsWith(".AppImage") || + fileName.endsWith(".deb") || + fileName.endsWith(".rpm") || + fileName === "latest-linux.yml" + ) { + await cp(sourcePath, join(linuxDir, fileName)); + } + } +} + +async function uploadWinAndLinuxToR2() { + await loadEnvFile(join(rootDir, ".env.release.local")); + await loadEnvFile(join(rootDir, ".env.release")); + const r2 = await prepareR2ReleaseHost({ rootDir }); + await uploadReleaseFiles({ + rootDir, + releaseDir: join(localReleaseDir, "win"), + bucket: r2.bucket, + releasePrefix: r2.releasePrefix, + }); + await uploadReleaseFiles({ + rootDir, + releaseDir: join(localReleaseDir, "linux"), + bucket: r2.bucket, + releasePrefix: r2.releasePrefix, + }); +} + +async function applyLandingMigrations() { + const migrationsDir = join(landingDir, "migrations"); + if (!existsSync(migrationsDir)) { + return; + } + + const wranglerToml = await readFile(join(landingDir, "wrangler.toml"), "utf8"); + const match = wranglerToml.match(/database_name\s*=\s*"([^"]+)"/); + if (!match) { + return; + } + const databaseName = match[1]; + const migrationFiles = (await readdir(migrationsDir)) + .filter((fileName) => fileName.endsWith(".sql")) + .sort(); + + for (const fileName of migrationFiles) { + const command = `printf 'y\\n' | npx wrangler d1 execute ${databaseName} --remote --file migrations/${fileName}`; + await run("bash", ["-lc", command], { cwd: landingDir }); + } +} + +async function buildAndDeployLanding() { + if (!existsSync(join(landingDir, "node_modules"))) { + await run("npm", ["ci"], { cwd: landingDir }); + } + await run("npm", ["run", "build"], { cwd: landingDir }); + const deploy = await run( + "npx", + ["wrangler", "pages", "deploy", "out", "--project-name", pagesProjectName, "--branch", pagesBranch, "--commit-dirty=true"], + { cwd: landingDir, capture: true }, + ); + const output = `${deploy.stdout}\n${deploy.stderr}`; + const urlMatch = output.match(/https:\/\/[^\s]+\.agentcompany\.pages\.dev/); + return urlMatch?.[0] ?? null; +} + +async function verifyPublishedAssets(version, deployUrl) { + const releaseLinksSource = await readFile(join(landingDir, "components", "release-links.generated.ts"), "utf8"); + const requiredFragments = [ + `AgentCompany-0.4.150-arm64.dmg`.replace("0.4.150", version), + `AgentCompany.Setup.${version}.exe`, + `agent-company-${version}.aarch64.rpm`, + `agent-company_${version}_arm64.deb`, + ]; + for (const fragment of requiredFragments) { + if (!releaseLinksSource.includes(fragment)) { + throw new Error(`Missing expected release asset in generated links: ${fragment}`); + } + } + + if (deployUrl) { + const { stdout } = await run("curl", ["-L", "--silent", "-A", "Mozilla/5.0", deployUrl], { capture: true }); + if (!stdout.includes(version)) { + throw new Error(`Deployed landing preview ${deployUrl} does not contain version ${version}.`); + } + } +} + +async function cleanupRemoteTransportRelease(repo, releaseTag) { + await run("gh", ["release", "delete", releaseTag, "--repo", repo.nameWithOwner, "--yes"]); +} + +async function cleanupRemoteBranch(repo, branchName) { + await run("gh", ["api", "-X", "DELETE", `repos/${repo.nameWithOwner}/git/refs/heads/${branchName}`]); +} + +async function cleanupLocalWorktree(tempDir) { + await run("git", ["worktree", "remove", "--force", tempDir]); + await run("git", ["worktree", "prune"]); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + await loadEnvFile(join(rootDir, ".env.release.local")); + await loadEnvFile(join(rootDir, ".env.release")); + + const repo = await getRepoInfo(); + await ensureRemoteBuildPrerequisites(repo); + + const currentVersion = (await readPackageJson()).version; + const nextVersion = options.version ?? bumpPatchVersion(currentVersion); + if (!versionPattern.test(nextVersion)) { + throw new Error(`Invalid version: ${nextVersion}`); + } + + await updatePackageVersion(nextVersion); + await resetReleaseStaging(); + + if (!options.skipVerify) { + await run("pnpm", ["verify"]); + } + + await buildMacAndUpload(); + + const timestamp = Date.now(); + const releaseTag = `transport-${nextVersion}-${timestamp}`; + const branchName = `release-build-${nextVersion.replace(/\./g, "-")}-${timestamp}`; + const releaseTitle = `Remote Release ${nextVersion}`; + + const tempDir = await createSnapshotBranch(repo, branchName, nextVersion); + let cleanupPending = true; + + try { + await dispatchRemoteWorkflow(repo, branchName, releaseTag, releaseTitle); + const runSummary = await waitForWorkflowRun(repo, branchName, 60_000); + const runInfo = await waitForWorkflowCompletion(repo, runSummary.databaseId, 45 * 60 * 1000); + if (runInfo.conclusion !== "success") { + throw new Error(`Remote workflow failed: ${runInfo.url}`); + } + + const downloadRoot = await downloadRemoteAssets(repo, releaseTag); + await stageRemoteAssets(downloadRoot); + await uploadWinAndLinuxToR2(); + await run("node", ["scripts/generate-landing-release-links.mjs"]); + await applyLandingMigrations(); + const deployUrl = await buildAndDeployLanding(); + await verifyPublishedAssets(nextVersion, deployUrl); + + if (!options.keepTransportRelease) { + await cleanupRemoteTransportRelease(repo, releaseTag); + } + if (options.deleteRemoteBranch) { + await cleanupRemoteBranch(repo, branchName); + } + await cleanupLocalWorktree(tempDir); + cleanupPending = false; + + console.log(JSON.stringify({ + version: nextVersion, + remoteBranch: branchName, + remoteWorkflowRun: runInfo.url, + releaseTag, + deployUrl, + updateUrl: process.env.AGENTCOMPANY_UPDATE_URL ?? null, + }, null, 2)); + } finally { + if (cleanupPending) { + try { + await cleanupLocalWorktree(tempDir); + } catch { + // no-op during best-effort temp cleanup + } + } + } +} + +await main(); diff --git a/src/main/api-server/routes-misc.ts b/src/main/api-server/routes-misc.ts index cd89b471..ebfd61d0 100644 --- a/src/main/api-server/routes-misc.ts +++ b/src/main/api-server/routes-misc.ts @@ -100,7 +100,7 @@ export function registerMiscRoutes(server: AgentApiServer) { const onBrowserAction = server.onBrowserAction_; if (onBrowserAction) { - onBrowserAction(actionId, companyId, input.socialAccountId, input.actionType, input.payload as Record); + onBrowserAction(actionId, companyId); } server.json(res, 201, { id: actionId, status: "queued", message: "Browser action queued for execution" }); @@ -183,10 +183,25 @@ export function registerMiscRoutes(server: AgentApiServer) { server.json(res, 403, { error: "Company mismatch" }); return; } - const data = body as Record; - const participantAgentIds = data.participantAgentIds ? String(data.participantAgentIds) : "[]"; + + const meetingCreateSchema = z.object({ + type: z.string().default("department_sync"), + title: z.string().default("Meeting"), + participantAgentIds: z.string().default("[]"), + scheduledAt: z.string().default(new Date().toISOString()), + durationMinutes: z.number().default(30), + agendaJson: z.string().default("[]"), + }); + + const parsed = meetingCreateSchema.safeParse(body); + if (!parsed.success) { + server.json(res, 400, { error: "Invalid meeting payload", details: parsed.error.issues }); + return; + } + + const input = parsed.data; try { - const parsedParticipants = JSON.parse(participantAgentIds) as string[]; + const parsedParticipants = JSON.parse(input.participantAgentIds) as string[]; for (const agentId of parsedParticipants) { if (typeof agentId !== "string" || !db.belongsToCompany("agents", agentId, token.companyId)) { server.json(res, 400, { error: "Meeting participants must belong to the current company" }); @@ -199,13 +214,13 @@ export function registerMiscRoutes(server: AgentApiServer) { } const id = db.saveMeeting({ companyId: token.companyId, - type: String(data.type ?? "department_sync"), - title: String(data.title ?? "Meeting"), + type: input.type, + title: input.title, organizerAgentId: token.agentId, - participantAgentIds, - scheduledAt: String(data.scheduledAt ?? new Date().toISOString()), - durationMinutes: Number(data.durationMinutes ?? 30), - agendaJson: data.agendaJson ? String(data.agendaJson) : "[]", + participantAgentIds: input.participantAgentIds, + scheduledAt: input.scheduledAt, + durationMinutes: input.durationMinutes, + agendaJson: input.agendaJson, status: "scheduled", }); onDomainChanged(); @@ -229,11 +244,28 @@ export function registerMiscRoutes(server: AgentApiServer) { server.json(res, 404, { error: "Meeting not found" }); return; } - const data = body as Record; + + const meetingUpdateSchema = z.object({ + type: z.string().optional(), + title: z.string().optional(), + notesJson: z.string().optional(), + decisionsJson: z.string().optional(), + actionItemsJson: z.string().optional(), + status: z.string().optional(), + scheduledAt: z.string().optional(), + }); + + const parsed = meetingUpdateSchema.safeParse(body); + if (!parsed.success) { + server.json(res, 400, { error: "Invalid meeting update payload", details: parsed.error.issues }); + return; + } + + const data = parsed.data; let actionItems: Array<{ title?: string; assigneeAgentId?: string; description?: string }> = []; if (data.actionItemsJson) { try { - actionItems = JSON.parse(String(data.actionItemsJson)) as Array<{ title?: string; assigneeAgentId?: string; description?: string }>; + actionItems = JSON.parse(data.actionItemsJson) as Array<{ title?: string; assigneeAgentId?: string; description?: string }>; } catch { server.json(res, 400, { error: "actionItemsJson must be valid JSON" }); return; @@ -248,13 +280,13 @@ export function registerMiscRoutes(server: AgentApiServer) { db.saveMeeting({ id: params.meetingId, companyId: token.companyId, - type: data.type ? String(data.type) : "department_sync", - title: data.title ? String(data.title) : "Meeting", - notesJson: data.notesJson ? String(data.notesJson) : undefined, - decisionsJson: data.decisionsJson ? String(data.decisionsJson) : undefined, - actionItemsJson: data.actionItemsJson ? String(data.actionItemsJson) : undefined, - status: data.status ? String(data.status) : undefined, - scheduledAt: data.scheduledAt ? String(data.scheduledAt) : new Date().toISOString(), + type: data.type ?? "department_sync", + title: data.title ?? "Meeting", + notesJson: data.notesJson, + decisionsJson: data.decisionsJson, + actionItemsJson: data.actionItemsJson, + status: data.status, + scheduledAt: data.scheduledAt ?? new Date().toISOString(), }); onDomainChanged(); // When action items are added, create tasks for them automatically @@ -291,17 +323,34 @@ export function registerMiscRoutes(server: AgentApiServer) { server.json(res, 403, { error: "Company mismatch" }); return; } - const data = body as Record; + + const knowledgeCreateSchema = z.object({ + category: z.string().default("lesson_learned"), + topic: z.string().default(""), + content: z.string().default(""), + importance: z.string().default("medium"), + tagsJson: z.string().default("[]"), + referencedEntityType: z.string().nullable().optional(), + referencedEntityId: z.string().nullable().optional(), + }); + + const parsed = knowledgeCreateSchema.safeParse(body); + if (!parsed.success) { + server.json(res, 400, { error: "Invalid knowledge entry payload", details: parsed.error.issues }); + return; + } + + const input = parsed.data; const id = db.saveKnowledgeEntry({ companyId: token.companyId, - category: String(data.category ?? "lesson_learned"), - topic: String(data.topic ?? ""), - content: String(data.content ?? ""), + category: input.category, + topic: input.topic, + content: input.content, authorAgentId: token.agentId, - importance: String(data.importance ?? "medium"), - tagsJson: data.tagsJson ? String(data.tagsJson) : "[]", - referencedEntityType: data.referencedEntityType ? String(data.referencedEntityType) : null, - referencedEntityId: data.referencedEntityId ? String(data.referencedEntityId) : null, + importance: input.importance, + tagsJson: input.tagsJson, + referencedEntityType: input.referencedEntityType ?? null, + referencedEntityId: input.referencedEntityId ?? null, sourceRunId: token.runId ?? null, }); onDomainChanged(); diff --git a/src/main/api-server/server-core.ts b/src/main/api-server/server-core.ts index 9237fdd6..6d638812 100644 --- a/src/main/api-server/server-core.ts +++ b/src/main/api-server/server-core.ts @@ -67,7 +67,7 @@ export class AgentApiServer { private emitEventCb: (event: DesktopEvent) => void; private callbacks: ApiServerEventCallbacks; private allowedOrigins: Set; - private onBrowserActionCb: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record) => void) | null = null; + private onBrowserActionCb: ((actionId: string, companyId: string) => void) | null = null; constructor( db: AppDatabase, @@ -97,7 +97,7 @@ export class AgentApiServer { }; } - setBrowserActionHandler(handler: (actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record) => void) { + setBrowserActionHandler(handler: (actionId: string, companyId: string) => void) { this.onBrowserActionCb = handler; } @@ -269,7 +269,7 @@ export class AgentApiServer { get onDomainChanged_(): () => void { return this.onDomainChangedCb; } get onWakeAgent_(): (agentId: string, companyId: string, trigger: string) => string | null { return this.onWakeAgentCb; } get emitEvent_(): (event: DesktopEvent) => void { return this.emitEventCb; } - get onBrowserAction_(): ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record) => void) | null { + get onBrowserAction_(): ((actionId: string, companyId: string) => void) | null { return this.onBrowserActionCb; } get port_(): number { return this.port; } diff --git a/src/main/browser-action-service.ts b/src/main/browser-action-service.ts index f2dbf402..3e2c1166 100644 --- a/src/main/browser-action-service.ts +++ b/src/main/browser-action-service.ts @@ -127,11 +127,16 @@ export function createBrowserActionService({ browserManagerRef.current = new BrowserManager(join(db.artifactsDir, "browser")); } + async function extractProfileFromSession(socialAccountId: string, platform: string) { + return requireBrowserManager().extractProfileFromSession(socialAccountId, platform); + } + return { dispatchBrowserAction, clearSocialAccountData, cancelAction, launchLoginBrowser, + extractProfileFromSession, resetBrowserManager, }; } diff --git a/src/main/browser-manager.ts b/src/main/browser-manager.ts index 7a0ddb6f..7c1c3596 100644 --- a/src/main/browser-manager.ts +++ b/src/main/browser-manager.ts @@ -231,6 +231,33 @@ export class BrowserManager { } } + async extractProfileFromSession(socialAccountId: string, platform: string): Promise<{ accountName: string; displayName: string }> { + const ses = this.getSession(socialAccountId); + const win = new BrowserWindow({ + show: false, + webPreferences: { session: ses, nodeIntegration: false, contextIsolation: true, sandbox: true }, + }); + + try { + const rules = getPlatformRules(platform); + await win.loadURL(rules.authCheckUrl); + await waitForLoad(win, 12000); + const result = await win.webContents.executeJavaScript(` + (() => { + const meta = document.querySelector('meta[property="al:ios:url"], meta[name="user-login"], meta[property="profile:username"]'); + if (meta) return { accountName: meta.getAttribute("content") || "", displayName: "" }; + const title = document.title || ""; + return { accountName: "", displayName: title.split(/[|–—\\-/]/).map(s => s.trim()).find(s => s.length > 0 && s.length < 40) || "" }; + })() + `); + return result && (result.accountName || result.displayName) ? result : { accountName: "", displayName: "" }; + } catch { + return { accountName: "", displayName: "" }; + } finally { + if (!win.isDestroyed()) win.close(); + } + } + shutdown() { for (const [, win] of this.loginWindows) { if (!win.isDestroyed()) win.close(); diff --git a/src/main/browser/browser-types.ts b/src/main/browser/browser-types.ts index 169df1b8..2f620e15 100644 --- a/src/main/browser/browser-types.ts +++ b/src/main/browser/browser-types.ts @@ -85,6 +85,83 @@ export const PLATFORM_RULES: Record = { cookieOrigins: ["https://bsky.app"], unauthenticatedUrlMarkers: ["/login"], }, + discord: { + loginUrl: "https://discord.com/login", + authCheckUrl: "https://discord.com/channels/@me", + allowedHosts: ["discord.com"], + cookieOrigins: ["https://discord.com"], + unauthenticatedUrlMarkers: ["/login"], + }, + threads: { + loginUrl: "https://www.threads.net/login", + authCheckUrl: "https://www.threads.net/", + allowedHosts: ["threads.net"], + cookieOrigins: ["https://www.threads.net"], + unauthenticatedUrlMarkers: ["/login"], + }, + pinterest: { + loginUrl: "https://www.pinterest.com/login/", + authCheckUrl: "https://www.pinterest.com/", + allowedHosts: ["pinterest.com"], + cookieOrigins: ["https://www.pinterest.com"], + unauthenticatedUrlMarkers: ["/login"], + }, + telegram: { + loginUrl: "https://web.telegram.org/", + authCheckUrl: "https://web.telegram.org/", + allowedHosts: ["telegram.org", "t.me"], + cookieOrigins: ["https://web.telegram.org"], + unauthenticatedUrlMarkers: ["/auth"], + }, + whatsapp: { + loginUrl: "https://web.whatsapp.com/", + authCheckUrl: "https://web.whatsapp.com/", + allowedHosts: ["whatsapp.com"], + cookieOrigins: ["https://web.whatsapp.com"], + unauthenticatedUrlMarkers: [], + }, + wechat: { + loginUrl: "https://mp.weixin.qq.com/", + authCheckUrl: "https://mp.weixin.qq.com/", + allowedHosts: ["mp.weixin.qq.com", "weixin.qq.com"], + cookieOrigins: ["https://mp.weixin.qq.com"], + unauthenticatedUrlMarkers: ["/cgi-bin/loginpage"], + }, + weibo: { + loginUrl: "https://weibo.com/login.php", + authCheckUrl: "https://weibo.com/", + allowedHosts: ["weibo.com", "weibo.cn"], + cookieOrigins: ["https://weibo.com"], + unauthenticatedUrlMarkers: ["/login", "/signup"], + }, + xiaohongshu: { + loginUrl: "https://www.xiaohongshu.com/explore", + authCheckUrl: "https://www.xiaohongshu.com/", + allowedHosts: ["xiaohongshu.com"], + cookieOrigins: ["https://www.xiaohongshu.com"], + unauthenticatedUrlMarkers: ["/login"], + }, + douyin: { + loginUrl: "https://www.douyin.com/", + authCheckUrl: "https://www.douyin.com/", + allowedHosts: ["douyin.com"], + cookieOrigins: ["https://www.douyin.com"], + unauthenticatedUrlMarkers: [], + }, + bilibili: { + loginUrl: "https://passport.bilibili.com/login", + authCheckUrl: "https://www.bilibili.com/", + allowedHosts: ["bilibili.com"], + cookieOrigins: ["https://www.bilibili.com", "https://passport.bilibili.com"], + unauthenticatedUrlMarkers: ["/login"], + }, + zhihu: { + loginUrl: "https://www.zhihu.com/signin", + authCheckUrl: "https://www.zhihu.com/", + allowedHosts: ["zhihu.com"], + cookieOrigins: ["https://www.zhihu.com"], + unauthenticatedUrlMarkers: ["/signin", "/sign-in"], + }, other: { loginUrl: "https://google.com", authCheckUrl: "https://google.com", diff --git a/src/main/browser/browser-url-helpers.ts b/src/main/browser/browser-url-helpers.ts index 07ce60ed..2a93a01e 100644 --- a/src/main/browser/browser-url-helpers.ts +++ b/src/main/browser/browser-url-helpers.ts @@ -71,6 +71,17 @@ export function getComposeUrl(platform: string): string { bluesky: "https://bsky.app/", youtube: "https://www.youtube.com/", tiktok: "https://www.tiktok.com/", + discord: "https://discord.com/channels/@me", + threads: "https://www.threads.net/", + pinterest: "https://www.pinterest.com/pin-creation-tool/", + telegram: "https://web.telegram.org/", + whatsapp: "https://web.whatsapp.com/", + wechat: "https://mp.weixin.qq.com/", + weibo: "https://weibo.com/", + xiaohongshu: "https://www.xiaohongshu.com/explore", + douyin: "https://www.douyin.com/", + bilibili: "https://www.bilibili.com/", + zhihu: "https://www.zhihu.com/question/waiting", }; return urls[platform] || getPlatformRules(platform).loginUrl; } @@ -85,6 +96,17 @@ export function getFeedUrl(platform: string): string { bluesky: "https://bsky.app/", youtube: "https://www.youtube.com/", tiktok: "https://www.tiktok.com/", + discord: "https://discord.com/channels/@me", + threads: "https://www.threads.net/", + pinterest: "https://www.pinterest.com/", + telegram: "https://web.telegram.org/", + whatsapp: "https://web.whatsapp.com/", + wechat: "https://mp.weixin.qq.com/", + weibo: "https://weibo.com/", + xiaohongshu: "https://www.xiaohongshu.com/explore", + douyin: "https://www.douyin.com/", + bilibili: "https://www.bilibili.com/", + zhihu: "https://www.zhihu.com/", }; return urls[platform] || getPlatformRules(platform).loginUrl; } @@ -95,6 +117,12 @@ export function getSearchUrl(platform: string, query: string): string { twitter: `https://x.com/search?q=${q}`, linkedin: `https://www.linkedin.com/search/results/all/?keywords=${q}`, reddit: `https://www.reddit.com/search/?q=${q}`, + discord: `https://discord.com/channels/@me`, + pinterest: `https://www.pinterest.com/search/pins/?q=${q}`, + weibo: `https://s.weibo.com/weibo?q=${q}`, + xiaohongshu: `https://www.xiaohongshu.com/search_result?keyword=${q}`, + bilibili: `https://search.bilibili.com/all?keyword=${q}`, + zhihu: `https://www.zhihu.com/search?type=content&q=${q}`, }; return urls[platform] || `https://google.com/search?q=${q}`; } diff --git a/src/main/budget-enforcement-service.ts b/src/main/budget-enforcement-service.ts index 08731c43..f99e550f 100644 --- a/src/main/budget-enforcement-service.ts +++ b/src/main/budget-enforcement-service.ts @@ -27,18 +27,16 @@ export interface BudgetGateResult { function getScopeLabel(db: AppDatabase, policy: BudgetPolicyRecord): string { if (policy.scopeType === "agent") { try { - const agents = db.listSnapshot({ companyId: null }).agents; - const agent = agents.find((a) => a.id === policy.scopeId); - return agent ? `Agent "${agent.name}"` : `Agent ${policy.scopeId.slice(0, 8)}`; + const agent = db.getAgent(policy.scopeId); + return `Agent "${agent.name}"`; } catch { return `Agent ${policy.scopeId.slice(0, 8)}`; } } if (policy.scopeType === "project") { try { - const projects = db.listSnapshot({ companyId: null }).projects; - const project = projects.find((p) => p.id === policy.scopeId); - return project ? `Project "${project.name}"` : `Project ${policy.scopeId.slice(0, 8)}`; + const row = db.queryAll<{ name: string }>("select name from projects where id = ?", policy.scopeId); + return row.length > 0 ? `Project "${row[0].name}"` : `Project ${policy.scopeId.slice(0, 8)}`; } catch { return `Project ${policy.scopeId.slice(0, 8)}`; } diff --git a/src/main/connector-definitions.ts b/src/main/connector-definitions.ts index c8843378..2e7f2b48 100644 --- a/src/main/connector-definitions.ts +++ b/src/main/connector-definitions.ts @@ -24,6 +24,51 @@ export interface ConnectorDefinition { listModels?: () => Promise>; } +const claudeCompatModule: ServerAdapterModule = { + type: "claude_local", + execute: claudeExecute, + testEnvironment: claudeTestEnvironment, + sessionCodec: claudeSessionCodec, + nativeContextManagement: true, + models: claudeModels, +}; + +const claudeCompatCapabilities: CapabilityMatrix = { + sessionResume: true, + gracefulCancel: true, + modelDiscovery: false, + structuredTranscript: true, + costAttribution: false, +}; + +const COMPAT_SLOT_IDS: ConnectorId[] = [ + "claude_compatible", + "claude_compat_2", + "claude_compat_3", + "claude_compat_4", + "claude_compat_5", + "claude_compat_6", + "claude_compat_7", + "claude_compat_8", + "claude_compat_9", + "claude_compat_10", +]; + +const claudeCompatSlots: ConnectorDefinition[] = COMPAT_SLOT_IDS.map((id, i) => ({ + id, + label: i === 0 ? "Claude Code Compatible API" : `Claude Code Compatible API (${i + 1})`, + description: i === 0 + ? "Claude Code CLI connected to a third-party API provider (e.g. DeepSeek, OpenRouter, Ollama)." + : `Claude Code CLI compatible API slot ${i + 1} for a third-party provider.`, + defaultCommand: "claude", + defaultModel: null, + notes: "", + capabilityMatrix: claudeCompatCapabilities, + configurationDoc: claudeDoc, + module: claudeCompatModule, + listModels: async () => claudeModels, +})); + export const CONNECTORS: ConnectorDefinition[] = [ { id: "codex_local", @@ -75,256 +120,7 @@ export const CONNECTORS: ConnectorDefinition[] = [ }, listModels: async () => claudeModels, }, - { - id: "claude_compatible", - label: "Claude Code Compatible API", - description: "Claude Code CLI connected to a third-party API provider (e.g. DeepSeek, OpenRouter, Ollama).", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_2", - label: "Claude Code Compatible API (2)", - description: "Second Claude Code CLI compatible API slot for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_3", - label: "Claude Code Compatible API (3)", - description: "Third Claude Code CLI compatible API slot for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_4", - label: "Claude Code Compatible API (4)", - description: "Claude Code CLI compatible API slot 4 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_5", - label: "Claude Code Compatible API (5)", - description: "Claude Code CLI compatible API slot 5 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_6", - label: "Claude Code Compatible API (6)", - description: "Claude Code CLI compatible API slot 6 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_7", - label: "Claude Code Compatible API (7)", - description: "Claude Code CLI compatible API slot 7 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_8", - label: "Claude Code Compatible API (8)", - description: "Claude Code CLI compatible API slot 8 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_9", - label: "Claude Code Compatible API (9)", - description: "Claude Code CLI compatible API slot 9 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, - { - id: "claude_compat_10", - label: "Claude Code Compatible API (10)", - description: "Claude Code CLI compatible API slot 10 for a third-party provider.", - defaultCommand: "claude", - defaultModel: null, - notes: "", - capabilityMatrix: { - sessionResume: true, - gracefulCancel: true, - modelDiscovery: false, - structuredTranscript: true, - costAttribution: false, - }, - configurationDoc: claudeDoc, - module: { - type: "claude_local", - execute: claudeExecute, - testEnvironment: claudeTestEnvironment, - sessionCodec: claudeSessionCodec, - nativeContextManagement: true, - models: claudeModels, - }, - listModels: async () => claudeModels, - }, + ...claudeCompatSlots, { id: "gemini_local", label: "Gemini CLI", diff --git a/src/main/ipc-browser-handlers.ts b/src/main/ipc-browser-handlers.ts index e786ddb8..48322601 100644 --- a/src/main/ipc-browser-handlers.ts +++ b/src/main/ipc-browser-handlers.ts @@ -89,18 +89,27 @@ export function registerBrowserHandlers({ return fail("ACCOUNT_NOT_FOUND", "Social account not found"); } - browserActions.launchLoginBrowser(input.socialAccountId, account.platform).then((result) => { + browserActions.launchLoginBrowser(input.socialAccountId, account.platform).then(async (result) => { if (result.success) { const latestAccount = db.getSocialAccount(input.socialAccountId, account.companyId); if (!latestAccount) { return; } + let detectedName = ""; + let detectedDisplay = ""; + try { + const profile = await browserActions.extractProfileFromSession(input.socialAccountId, account.platform); + detectedName = profile.accountName; + detectedDisplay = profile.displayName; + } catch { + // Profile extraction is best-effort; proceed with login status update + } db.saveSocialAccount({ id: latestAccount.id, companyId: latestAccount.companyId, platform: latestAccount.platform, - accountName: latestAccount.accountName, - displayName: latestAccount.displayName, + accountName: detectedName || latestAccount.accountName, + displayName: detectedDisplay || latestAccount.displayName, profileUrl: latestAccount.profileUrl, credentialSecretId: latestAccount.credentialSecretId, status: "logged_in", diff --git a/src/main/ipc-domain-common.ts b/src/main/ipc-domain-common.ts index dbc0f08c..1dce244f 100644 --- a/src/main/ipc-domain-common.ts +++ b/src/main/ipc-domain-common.ts @@ -17,6 +17,10 @@ interface BrowserActionControl { screenshotPath: string | null; error: string | null; }>; + extractProfileFromSession: (socialAccountId: string, platform: string) => Promise<{ + accountName: string; + displayName: string; + }>; } export interface RegisterDomainHandlersDependencies { @@ -48,10 +52,4 @@ export interface RegisterDomainHandlersDependencies { onHumanMessage?: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void; } -export function ok(data: T): DesktopResult { - return { ok: true, data }; -} - -export function fail(code: string, message: string): DesktopResult { - return { ok: false, error: { code, message } }; -} +export { ok, fail } from "./ipc-core-common"; diff --git a/src/main/message-normalize.ts b/src/main/message-normalize.ts index 6508e8d3..d79c6eea 100644 --- a/src/main/message-normalize.ts +++ b/src/main/message-normalize.ts @@ -77,135 +77,6 @@ export function normalizeMessagesForPrompt( // Applied when session state is restored, not at every message send. // --------------------------------------------------------------------------- -/** - * A transcript entry from adapter session state. - * Adapters serialize their conversation as arrays of these blocks. - */ -export interface TranscriptBlock { - role: "user" | "assistant" | "system"; - content: string | TranscriptContentBlock[]; -} - -export interface TranscriptContentBlock { - type: string; - id?: string; - tool_use_id?: string; - name?: string; - input?: unknown; - content?: string; - text?: string; -} - -/** - * Repair tool_use / tool_result pairing in a serialized transcript. - * - Orphan tool_results (no matching tool_use) are removed. - * - Unpaired tool_use blocks get a synthetic "[interrupted]" result appended. - * - Duplicate tool_use IDs are deduplicated (keep first). - * - * Returns a repaired copy; does not mutate the input. - */ -export function repairToolPairing(transcript: TranscriptBlock[]): TranscriptBlock[] { - const toolUseIds = new Set(); - const toolResultIds = new Set(); - - // Pass 1: collect all tool_use and tool_result IDs - for (const block of transcript) { - if (!Array.isArray(block.content)) continue; - for (const part of block.content) { - if (part.type === "tool_use" && part.id) { - toolUseIds.add(part.id); - } - if (part.type === "tool_result" && part.tool_use_id) { - toolResultIds.add(part.tool_use_id); - } - } - } - - // Pass 2: filter orphan tool_results and deduplicate tool_use IDs - const seenToolUseIds = new Set(); - const repaired: TranscriptBlock[] = []; - - for (const block of transcript) { - if (typeof block.content === "string") { - repaired.push(block); - continue; - } - - const filteredContent: TranscriptContentBlock[] = []; - for (const part of block.content) { - if (part.type === "tool_result" && part.tool_use_id && !toolUseIds.has(part.tool_use_id)) { - continue; // orphan tool_result — remove - } - if (part.type === "tool_use" && part.id) { - if (seenToolUseIds.has(part.id)) continue; // duplicate — remove - seenToolUseIds.add(part.id); - } - filteredContent.push(part); - } - - if (filteredContent.length > 0) { - repaired.push({ ...block, content: filteredContent }); - } - } - - // Pass 3: add synthetic results for unpaired tool_use blocks - const unpairedToolUseIds = [...toolUseIds].filter((id) => !toolResultIds.has(id)); - if (unpairedToolUseIds.length > 0) { - const syntheticResults: TranscriptContentBlock[] = unpairedToolUseIds.map((id) => ({ - type: "tool_result", - tool_use_id: id, - content: "[interrupted — tool call did not complete before session ended]", - })); - repaired.push({ - role: "user", - content: syntheticResults, - }); - } - - return repaired; -} - -/** - * Validate and return repair statistics for a transcript. - * Use for logging/diagnostics without modifying the transcript. - */ -export function diagnoseTranscript(transcript: TranscriptBlock[]): { - totalBlocks: number; - toolUseCount: number; - toolResultCount: number; - orphanResultCount: number; - unpairedUseCount: number; -} { - const toolUseIds = new Set(); - const toolResultIds = new Set(); - let toolUseCount = 0; - let toolResultCount = 0; - - for (const block of transcript) { - if (!Array.isArray(block.content)) continue; - for (const part of block.content) { - if (part.type === "tool_use" && part.id) { - toolUseIds.add(part.id); - toolUseCount++; - } - if (part.type === "tool_result" && part.tool_use_id) { - toolResultIds.add(part.tool_use_id); - toolResultCount++; - } - } - } - - const orphanResultCount = [...toolResultIds].filter((id) => !toolUseIds.has(id)).length; - const unpairedUseCount = [...toolUseIds].filter((id) => !toolResultIds.has(id)).length; - - return { - totalBlocks: transcript.length, - toolUseCount, - toolResultCount, - orphanResultCount, - unpairedUseCount, - }; -} export interface CommentRecord { taskId: string; diff --git a/src/main/task-rubrics.ts b/src/main/task-rubrics.ts index 22841ee2..fc0e627c 100644 --- a/src/main/task-rubrics.ts +++ b/src/main/task-rubrics.ts @@ -55,7 +55,7 @@ const RUBRICS: Record = { }; /** Returns the rubric for the given task type, defaulting to "general" for unknown values. */ -export function getTaskTypeRubric(taskType: string): TaskRubric { +function getTaskTypeRubric(taskType: string): TaskRubric { const key = taskType as TaskType; return RUBRICS[key] ?? RUBRICS.general; } diff --git a/src/main/thread-integration.ts b/src/main/thread-integration/index.ts similarity index 76% rename from src/main/thread-integration.ts rename to src/main/thread-integration/index.ts index 58fc1246..91434c5a 100644 --- a/src/main/thread-integration.ts +++ b/src/main/thread-integration/index.ts @@ -1,154 +1,16 @@ /** Thread Integration — wires thread runtime into orchestration callbacks. */ -import type { AppDatabase } from "./database"; -import type { ExecutionMode, ThreadType } from "@shared/types"; +import type { ExecutionMode } from "@shared/types"; +import { + type ThreadIntegrationDeps, + findTaskThread, + ensureTaskThread, + ensureApprovalThread, + announceApprovalToBoard, + notifyThreadSubscribers, +} from "./thread-helpers"; -interface ThreadIntegrationDeps { - db: AppDatabase; - logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; - emitEvent?: (event: import("@shared/types").DesktopEvent) => void; - publishDomainChanged?: () => void; - notify?: (options: { title: string; body: string }) => void; -} - -function findTaskThread(db: AppDatabase, taskId: string, companyId: string) { - const existing = db.listThreads({ - companyId, - type: "task" as ThreadType, - relatedEntityType: "task", - relatedEntityId: taskId, - limit: 1, - }); - return existing[0] ?? null; -} - -function ensureTaskThread(db: AppDatabase, taskId: string, companyId: string, title: string) { - const existing = findTaskThread(db, taskId, companyId); - if (existing) return existing; - - return db.createThread({ - companyId, - title, - type: "task" as ThreadType, - relatedEntityType: "task", - relatedEntityId: taskId, - }); -} - -function findApprovalThread(db: AppDatabase, approvalId: string, companyId: string) { - const existing = db.listThreads({ - companyId, - type: "approval" as ThreadType, - relatedEntityType: "approval", - relatedEntityId: approvalId, - limit: 1, - }); - return existing[0] ?? null; -} - -function ensureApprovalThread(db: AppDatabase, approvalId: string, companyId: string, title: string) { - const existing = findApprovalThread(db, approvalId, companyId); - if (existing) return existing; - - return db.createThread({ - companyId, - title, - type: "approval" as ThreadType, - relatedEntityType: "approval", - relatedEntityId: approvalId, - }); -} - -function resolveApprovalAnnouncementChannel( - db: AppDatabase, - companyId: string, - taskId: string | null, - requestedByAgentId: string | null, -): { channel: "department" | "project" | "company"; channelTargetId: string | null } | null { - if (requestedByAgentId) { - try { - const requester = db.getAgent(requestedByAgentId); - if (requester.companyId === companyId && requester.department) { - return { channel: "department", channelTargetId: requester.department }; - } - } catch { - // Fall through to task/project/company targeting. - } - } - - if (taskId) { - try { - const task = db.getTask(taskId); - if (task.companyId === companyId && task.projectId) { - return { channel: "project", channelTargetId: task.projectId }; - } - } catch { - // Fall through to company-wide routing. - } - } - - return { channel: "company", channelTargetId: null }; -} - -function announceApprovalToBoard( - db: AppDatabase, - approval: { - id: string; - companyId: string; - relatedTaskId: string | null; - requestedByAgentId: string | null; - payloadSummary: string; - impactSummary: string; - }, - deps: Pick, -): void { - if (!approval.requestedByAgentId || !deps.emitEvent || !deps.publishDomainChanged) return; - - const target = resolveApprovalAnnouncementChannel( - db, - approval.companyId, - approval.relatedTaskId, - approval.requestedByAgentId, - ); - if (!target) return; - - const messageId = db.sendAgentMessage({ - companyId: approval.companyId, - fromAgentId: approval.requestedByAgentId, - channel: target.channel, - channelTargetId: target.channelTargetId, - subject: approval.payloadSummary, - body: `@Board ${approval.impactSummary || approval.payloadSummary}`, - priority: "urgent", - senderKind: "agent", - }); - const message = db.getAgentMessage(messageId, approval.companyId); - if (message) { - deps.emitEvent({ type: "new-message", message }); - } - deps.publishDomainChanged(); -} - -/** Check subscriber notifyOn preferences and fire desktop notification. */ -function notifyThreadSubscribers( - db: AppDatabase, - threadId: string, - threadTitle: string, - eventBody: string, - notify?: (options: { title: string; body: string }) => void, -): void { - if (!notify) return; - const subscriptions = db.listSubscriptions(threadId); - for (const sub of subscriptions) { - if (sub.subscriberType !== "user") continue; - if (sub.notifyOn === "none") continue; - if (sub.notifyOn === "mentions") { - if (!eventBody.includes("@You") && !eventBody.includes("@user") && !eventBody.includes("@Board")) continue; - } - notify({ title: threadTitle, body: eventBody }); - break; - } -} +export type { ThreadIntegrationDeps } from "./thread-helpers"; export function createThreadIntegration({ db, logger, emitEvent, publishDomainChanged, notify }: ThreadIntegrationDeps) { return { diff --git a/src/main/thread-integration/thread-helpers.ts b/src/main/thread-integration/thread-helpers.ts new file mode 100644 index 00000000..e1d0eb22 --- /dev/null +++ b/src/main/thread-integration/thread-helpers.ts @@ -0,0 +1,151 @@ +/** Thread Integration — shared helpers and types. */ + +import type { AppDatabase } from "../database"; +import type { ThreadType } from "@shared/types"; + +export interface ThreadIntegrationDeps { + db: AppDatabase; + logger: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; + emitEvent?: (event: import("@shared/types").DesktopEvent) => void; + publishDomainChanged?: () => void; + notify?: (options: { title: string; body: string }) => void; +} + +export function findTaskThread(db: AppDatabase, taskId: string, companyId: string) { + const existing = db.listThreads({ + companyId, + type: "task" as ThreadType, + relatedEntityType: "task", + relatedEntityId: taskId, + limit: 1, + }); + return existing[0] ?? null; +} + +export function ensureTaskThread(db: AppDatabase, taskId: string, companyId: string, title: string) { + const existing = findTaskThread(db, taskId, companyId); + if (existing) return existing; + + return db.createThread({ + companyId, + title, + type: "task" as ThreadType, + relatedEntityType: "task", + relatedEntityId: taskId, + }); +} + +export function findApprovalThread(db: AppDatabase, approvalId: string, companyId: string) { + const existing = db.listThreads({ + companyId, + type: "approval" as ThreadType, + relatedEntityType: "approval", + relatedEntityId: approvalId, + limit: 1, + }); + return existing[0] ?? null; +} + +export function ensureApprovalThread(db: AppDatabase, approvalId: string, companyId: string, title: string) { + const existing = findApprovalThread(db, approvalId, companyId); + if (existing) return existing; + + return db.createThread({ + companyId, + title, + type: "approval" as ThreadType, + relatedEntityType: "approval", + relatedEntityId: approvalId, + }); +} + +function resolveApprovalAnnouncementChannel( + db: AppDatabase, + companyId: string, + taskId: string | null, + requestedByAgentId: string | null, +): { channel: "department" | "project" | "company"; channelTargetId: string | null } | null { + if (requestedByAgentId) { + try { + const requester = db.getAgent(requestedByAgentId); + if (requester.companyId === companyId && requester.department) { + return { channel: "department", channelTargetId: requester.department }; + } + } catch { + // Fall through to task/project/company targeting. + } + } + + if (taskId) { + try { + const task = db.getTask(taskId); + if (task.companyId === companyId && task.projectId) { + return { channel: "project", channelTargetId: task.projectId }; + } + } catch { + // Fall through to company-wide routing. + } + } + + return { channel: "company", channelTargetId: null }; +} + +export function announceApprovalToBoard( + db: AppDatabase, + approval: { + id: string; + companyId: string; + relatedTaskId: string | null; + requestedByAgentId: string | null; + payloadSummary: string; + impactSummary: string; + }, + deps: Pick, +): void { + if (!approval.requestedByAgentId || !deps.emitEvent || !deps.publishDomainChanged) return; + + const target = resolveApprovalAnnouncementChannel( + db, + approval.companyId, + approval.relatedTaskId, + approval.requestedByAgentId, + ); + if (!target) return; + + const messageId = db.sendAgentMessage({ + companyId: approval.companyId, + fromAgentId: approval.requestedByAgentId, + channel: target.channel, + channelTargetId: target.channelTargetId, + subject: approval.payloadSummary, + body: `@Board ${approval.impactSummary || approval.payloadSummary}`, + priority: "urgent", + senderKind: "agent", + }); + const message = db.getAgentMessage(messageId, approval.companyId); + if (message) { + deps.emitEvent({ type: "new-message", message }); + } + deps.publishDomainChanged(); +} + +/** Check subscriber notifyOn preferences and fire desktop notification. */ +export function notifyThreadSubscribers( + db: AppDatabase, + threadId: string, + threadTitle: string, + eventBody: string, + notify?: (options: { title: string; body: string }) => void, +): void { + if (!notify) return; + const subscriptions = db.listSubscriptions(threadId); + for (const sub of subscriptions) { + if (sub.subscriberType !== "user") continue; + if (sub.notifyOn === "none") continue; + if (sub.notifyOn === "mentions") { + if (!eventBody.includes("@You") && !eventBody.includes("@user") && !eventBody.includes("@Board")) continue; + } + notify({ title: threadTitle, body: eventBody }); + break; + } +} diff --git a/src/main/orchestrator/worker.ts b/src/main/worker/worker-entry.ts similarity index 100% rename from src/main/orchestrator/worker.ts rename to src/main/worker/worker-entry.ts diff --git a/src/main/worker/worker-message-handler.ts b/src/main/worker/worker-message-handler.ts index ac05b5d7..50549290 100644 --- a/src/main/worker/worker-message-handler.ts +++ b/src/main/worker/worker-message-handler.ts @@ -78,10 +78,12 @@ export async function handleWorkerMessage( const now = Date.now(); const lastRenewal = leaseRenewalMap.get(event.runId) ?? 0; if (now - lastRenewal >= LEASE_RENEWAL_INTERVAL_MS) { - const run = db.listSnapshot().runs.find((r) => r.id === event.runId); - if (run) { + try { + const run = db.getRun(event.runId); db.renewTaskLease(run.taskId, run.id); leaseRenewalMap.set(event.runId, now); + } catch { + // Run not found — skip lease renewal } } return; @@ -102,14 +104,16 @@ export async function handleWorkerMessage( }); } if (event.status === "running") { - const run = db.listSnapshot().runs.find((entry) => entry.id === event.runId); - if (run?.workspaceId && !db.claimWorkspaceLock(run.workspaceId, run.id)) { - logger.error(`[workspace] Failed to claim lock for workspace ${run.workspaceId} when run ${run.id} entered running state. Cancelling run.`); - worker?.postMessage({ type: "cancel-run", runId: run.id }); - } - if (run) { + try { + const run = db.getRun(event.runId); + if (run.workspaceId && !db.claimWorkspaceLock(run.workspaceId, run.id)) { + logger.error(`[workspace] Failed to claim lock for workspace ${run.workspaceId} when run ${run.id} entered running state. Cancelling run.`); + worker?.postMessage({ type: "cancel-run", runId: run.id }); + } db.renewTaskLease(run.taskId, run.id); onRunStarted?.(run.id, run.taskId, run.agentId, run.companyId); + } catch { + // Run not found — skip status handling } } emitEvent(event); @@ -117,9 +121,10 @@ export async function handleWorkerMessage( return; } - const snapshot = db.listSnapshot(); - const run = snapshot.runs.find((entry) => entry.id === event.runId); - if (!run) { + let run: ReturnType; + try { + run = db.getRun(event.runId); + } catch { return; } diff --git a/src/renderer/components/CompanyOnboarding/ClaudeSettingsDialog.tsx b/src/renderer/components/CompanyOnboarding/ClaudeSettingsDialog.tsx deleted file mode 100644 index 2b743754..00000000 --- a/src/renderer/components/CompanyOnboarding/ClaudeSettingsDialog.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useEffect } from "react"; -import { X } from "@phosphor-icons/react"; -import type { AppLocale } from "@shared/locale"; -import { useFocusTrap } from "../ui"; - -interface ClaudeSettingsDialogProps { - claudeApiKey: string; - setClaudeApiKey: (v: string) => void; - claudeBaseUrl: string; - setClaudeBaseUrl: (v: string) => void; - claudeModel: string; - setClaudeModel: (v: string) => void; - onSave: () => void; - onClose: () => void; - saving: boolean; - currentLocale: AppLocale; -} - -export function ClaudeSettingsDialog({ - claudeApiKey, - setClaudeApiKey, - claudeBaseUrl, - setClaudeBaseUrl, - claudeModel, - setClaudeModel, - onSave, - onClose, - saving, - currentLocale, -}: ClaudeSettingsDialogProps) { - const trapRef = useFocusTrap(true); - useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey) }, [onClose]); - return ( -
-
e.stopPropagation()}> -
-

Claude Code {currentLocale === "zh" ? "设置" : "Settings"}

- -
-
-
- - setClaudeApiKey(e.target.value)} - placeholder="sk-ant-..." - /> -
{currentLocale === "zh" ? "留空使用订阅认证" : "Leave empty to use subscription auth"}
-
-
- - setClaudeBaseUrl(e.target.value)} - placeholder="https://api.anthropic.com" - /> -
{currentLocale === "zh" ? "留空使用默认端点" : "Leave empty for default endpoint"}
-
-
- - setClaudeModel(e.target.value)} - placeholder="claude-sonnet-4-20250514" - /> -
{currentLocale === "zh" ? "留空使用连接器默认模型" : "Leave empty for connector default"}
-
-
-
- - -
-
-
- ); -} diff --git a/src/renderer/components/CompanyOnboarding/ConnectorReadiness.tsx b/src/renderer/components/CompanyOnboarding/ConnectorReadiness.tsx deleted file mode 100644 index b3b84565..00000000 --- a/src/renderer/components/CompanyOnboarding/ConnectorReadiness.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { - CheckCircle, - GearSix, - WarningCircle, - XCircle, -} from "@phosphor-icons/react"; -import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; -import type { ProfileSnapshot } from "@shared/types"; -import type { AppLocale } from "@shared/locale"; - -interface ConnectorReadinessProps { - connectors: ProfileSnapshot["connectors"]; - currentLocale: AppLocale; - hasRunnableConnector: boolean; - onOpenClaudeSettings: () => void; - t: (key: string) => string; -} - -export function ConnectorReadiness({ - connectors, - currentLocale, - hasRunnableConnector, - onOpenClaudeSettings, - t, -}: ConnectorReadinessProps) { - return ( -
-
- {t("onboarding.connectorReadiness")} -
-
- {connectors.map((connector) => { - const isChecking = connector.status === "detected"; - const isReady = connector.status === "ready"; - const isMissing = connector.status === "not_installed"; - const issue = !isChecking ? getConnectorExecutionReadinessIssue(connector) : null; - return ( -
- {isChecking ? ( - - ) : isReady ? ( - - ) : isMissing ? ( - - ) : ( - - )} -
-
- - {connector.label} - - - {isChecking ? (currentLocale === "zh" ? "检测中..." : "checking...") : connector.status.replaceAll("_", " ")} - -
- {issue ? ( -
- {issue} -
- ) : null} -
- {connector.id === "claude_local" ? ( - - ) : null} -
- ); - })} -
- {!hasRunnableConnector ? ( -
- {t("onboarding.connectorWarning")} -
- ) : null} -
- ); -} diff --git a/src/renderer/components/CompanyOnboarding/LanguageStep.tsx b/src/renderer/components/CompanyOnboarding/LanguageStep.tsx deleted file mode 100644 index 4f9b692e..00000000 --- a/src/renderer/components/CompanyOnboarding/LanguageStep.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { motion } from "framer-motion"; -import { CheckCircle, GlobeSimple } from "@phosphor-icons/react"; -import type { AppLocale } from "@shared/locale"; -import { APP_LOCALES, LOCALE_LABELS } from "@shared/locale"; -import { ActionButton } from "../ui"; - -interface LanguageStepProps { - currentLocale: AppLocale; - onLocaleChange: (locale: AppLocale) => void; - onContinue: () => void; - t: (key: string) => string; -} - -export function LanguageStep({ - currentLocale, - onLocaleChange, - onContinue, - t, -}: LanguageStepProps) { - return ( - -
- - {t("onboarding.languageSelection")} / Language -
-
- {t("onboarding.selectLanguage")} / Select Language -
-
- -
- {APP_LOCALES.map((locale) => ( - - ))} -
- -
- -
- - ); -} diff --git a/src/renderer/components/social/AddAccountDialog.tsx b/src/renderer/components/social/AddAccountDialog.tsx index bcee52c0..e4283da7 100644 --- a/src/renderer/components/social/AddAccountDialog.tsx +++ b/src/renderer/components/social/AddAccountDialog.tsx @@ -1,19 +1,17 @@ import { useState } from "react"; import { motion } from "framer-motion"; -import { Plus, X } from "@phosphor-icons/react"; +import { SignIn, X } from "@phosphor-icons/react"; import type { SocialPlatform } from "@shared/types"; -import { ActionButton, Input } from "../ui"; +import { ActionButton } from "../ui"; import { platformConfig } from "./social-config"; interface AddAccountDialogProps { - onAdd: (platform: SocialPlatform, accountName: string, displayName: string, requireApproval: boolean) => void; + onAddAndLogin: (platform: SocialPlatform, requireApproval: boolean) => void; onClose: () => void; } -export function AddAccountDialog({ onAdd, onClose }: AddAccountDialogProps) { +export function AddAccountDialog({ onAddAndLogin, onClose }: AddAccountDialogProps) { const [platform, setPlatform] = useState("twitter"); - const [accountName, setAccountName] = useState(""); - const [displayName, setDisplayName] = useState(""); const [requireApproval, setRequireApproval] = useState(true); return ( @@ -41,41 +39,29 @@ export function AddAccountDialog({ onAdd, onClose }: AddAccountDialogProps) {
-
- {(Object.entries(platformConfig) as [SocialPlatform, typeof platformConfig.twitter][]).map(([key, config]) => ( - - ))} +
+
+ {(Object.entries(platformConfig) as [SocialPlatform, typeof platformConfig.twitter][]).map(([key, config]) => ( + + ))} +
- - - -
+ +
+ A browser window will open for you to sign in. Your username and profile info will be detected automatically after login. +
@@ -103,15 +93,10 @@ export function AddAccountDialog({ onAdd, onClose }: AddAccountDialogProps) { Cancel { - if (accountName.trim()) { - onAdd(platform, accountName.trim(), displayName.trim(), requireApproval); - } - }} - icon={Plus} + label="Login" + onClick={() => onAddAndLogin(platform, requireApproval)} + icon={SignIn} tone="accent" - disabled={!accountName.trim()} />
diff --git a/src/renderer/components/social/SocialPanel.tsx b/src/renderer/components/social/SocialPanel.tsx index 920e7008..cfbb0c68 100644 --- a/src/renderer/components/social/SocialPanel.tsx +++ b/src/renderer/components/social/SocialPanel.tsx @@ -13,13 +13,7 @@ interface SocialPanelProps { actions: BrowserActionRecord[]; agents: Array<{ id: string; name: string }>; companyId: string; - onSave: (input: { - companyId: string; - platform: SocialPlatform; - accountName: string; - displayName: string; - requireApproval: boolean; - }) => Promise; + onAddAndLogin: (platform: SocialPlatform, requireApproval: boolean) => Promise; onDelete: (id: string) => Promise; onLogin: (accountId: string) => Promise; onOpenPath: (path: string) => Promise; @@ -31,8 +25,8 @@ export function SocialAccountsPanel({ accounts, actions, agents, - companyId, - onSave, + companyId: _companyId, + onAddAndLogin, onDelete, onLogin, onOpenPath, @@ -43,10 +37,10 @@ export function SocialAccountsPanel({ const [showAddDialog, setShowAddDialog] = useState(false); const [activeTab, setActiveTab] = useState<"accounts" | "history">("accounts"); - const handleAdd = useCallback(async (platform: SocialPlatform, accountName: string, displayName: string, requireApproval: boolean) => { - await onSave({ companyId, platform, accountName, displayName, requireApproval }); + const handleAddAndLogin = useCallback(async (platform: SocialPlatform, requireApproval: boolean) => { + await onAddAndLogin(platform, requireApproval); setShowAddDialog(false); - }, [companyId, onSave]); + }, [onAddAndLogin]); return (
@@ -151,7 +145,7 @@ export function SocialAccountsPanel({ {showAddDialog ? ( void handleAdd(platform, accountName, displayName, requireApproval)} + onAddAndLogin={(platform, requireApproval) => void handleAddAndLogin(platform, requireApproval)} onClose={() => setShowAddDialog(false)} /> ) : null} diff --git a/src/renderer/components/social/social-config.ts b/src/renderer/components/social/social-config.ts index 3649e814..1becfd55 100644 --- a/src/renderer/components/social/social-config.ts +++ b/src/renderer/components/social/social-config.ts @@ -1,6 +1,5 @@ import type { ElementType } from "react"; import { - Browser, Camera, ChatCircle, Eye, @@ -9,14 +8,23 @@ import { MagnifyingGlass, PaperPlaneTilt, UserPlus, + DiscordLogo, FacebookLogo, GlobeHemisphereWest, InstagramLogo, LinkedinLogo, + PinterestLogo, RedditLogo, + TelegramLogo, TiktokLogo, + WhatsappLogo, XLogo, YoutubeLogo, + ChatTeardrop, + MonitorPlay, + Notebook, + MusicNote, + Lightbulb, } from "@phosphor-icons/react"; import type { SocialPlatform } from "@shared/types"; @@ -29,6 +37,17 @@ export const platformConfig: Record void; - navigateTo: (section: SectionId, entityId?: string) => void; - setSection: (section: SectionId) => void; - setError: (error: string | null) => void; - openPath: (targetPath: string) => Promise; -} - -const CompanyContext = createContext(null); - -interface CompanyContextProviderProps { - companyId: string | null; - currentCompany: CompanyRecord | null; - snapshot: ProfileSnapshot | null; - refresh: () => void; - navigateTo: (section: SectionId, entityId?: string) => void; - setSection: (section: SectionId) => void; - setError: (error: string | null) => void; - openPath: (targetPath: string) => Promise; - children: ReactNode; -} - -export function CompanyContextProvider({ - companyId, currentCompany, snapshot, refresh, - navigateTo, setSection, setError, openPath, children, -}: CompanyContextProviderProps) { - const value = useMemo(() => ({ - companyId, currentCompany, snapshot, refresh, - navigateTo, setSection, setError, openPath, - }), [companyId, currentCompany, snapshot, refresh, navigateTo, setSection, setError, openPath]); - - return {children}; -} - -export function useCompanyContext(): CompanyContextValue { - const ctx = useContext(CompanyContext); - if (!ctx) throw new Error("useCompanyContext must be used within CompanyContextProvider"); - return ctx; -} diff --git a/src/renderer/lib/keyboard.ts b/src/renderer/lib/keyboard.ts index 35e1036d..0da37990 100644 --- a/src/renderer/lib/keyboard.ts +++ b/src/renderer/lib/keyboard.ts @@ -1,3 +1,5 @@ +import { useCallback, useRef, useState } from "react"; + interface RovingTabIndexResult { containerRef: { current: HTMLDivElement | null }; focusedIndex: number; @@ -6,11 +8,11 @@ interface RovingTabIndexResult { getTabIndex: (index: number) => 0 | -1; } -/** Roving tabindex for keyboard nav in lists. Supports Arrow/Home/End/j/k with wrap-around. */ -export function useRovingTabIndex(itemCount: number): RovingTabIndexResult { - // Use internal mutable state for testability outside React - // In a React component context, this still works correctly because - // the function is called once per render and returns fresh closures +/** + * Core roving-tabindex logic, independent of React. + * Used directly by tests and wrapped by `useRovingTabIndex` for React components. + */ +export function createRovingTabIndex(itemCount: number): RovingTabIndexResult { let _focusedIndex = 0; const containerRef = { current: null as HTMLDivElement | null }; @@ -41,7 +43,6 @@ export function useRovingTabIndex(itemCount: number): RovingTabIndexResult { _focusedIndex = nextIndex; - // Attempt to focus the target element in the DOM (no-op in tests) const target = containerRef.current?.querySelector( `[data-index="${nextIndex}"]`, ) as HTMLElement | null; @@ -62,3 +63,57 @@ export function useRovingTabIndex(itemCount: number): RovingTabIndexResult { getTabIndex, }; } + +/** React hook wrapper around roving tabindex. Uses real React state for reactivity in components. */ +export function useRovingTabIndex(itemCount: number): RovingTabIndexResult { + const [focusedIndex, setFocusedIndexState] = useState(0); + const containerRef = useRef(null); + const focusedRef = useRef(0); + + const setFocusedIndex = useCallback((index: number) => { + focusedRef.current = index; + setFocusedIndexState(index); + }, []); + + const handleKeyDown = useCallback((event: React.KeyboardEvent | { key: string; preventDefault: () => void }) => { + if (itemCount === 0) return; + + let nextIndex = focusedRef.current; + + if (event.key === "ArrowDown" || event.key === "j") { + event.preventDefault(); + nextIndex = (focusedRef.current + 1) % itemCount; + } else if (event.key === "ArrowUp" || event.key === "k") { + event.preventDefault(); + nextIndex = (focusedRef.current - 1 + itemCount) % itemCount; + } else if (event.key === "Home") { + event.preventDefault(); + nextIndex = 0; + } else if (event.key === "End") { + event.preventDefault(); + nextIndex = itemCount - 1; + } else { + return; + } + + focusedRef.current = nextIndex; + setFocusedIndexState(nextIndex); + + const target = containerRef.current?.querySelector( + `[data-index="${nextIndex}"]`, + ) as HTMLElement | null; + target?.focus(); + }, [itemCount]); + + const getTabIndex = useCallback((index: number): 0 | -1 => { + return index === focusedRef.current ? 0 : -1; + }, []); + + return { + containerRef, + focusedIndex, + setFocusedIndex, + handleKeyDown, + getTabIndex, + }; +} diff --git a/src/renderer/views/sections/OperationsSections.tsx b/src/renderer/views/sections/OperationsSections.tsx index ce9ce7d9..d76c3a84 100644 --- a/src/renderer/views/sections/OperationsSections.tsx +++ b/src/renderer/views/sections/OperationsSections.tsx @@ -153,19 +153,21 @@ export function OperationsSections({ actions={browserActions} agents={companyAgents.map(a => ({ id: a.id, name: a.name }))} companyId={snapshot.currentCompanyId} - onSave={async (input) => { + onAddAndLogin={async (platform, requireApproval) => { + if (!snapshot?.currentCompanyId) return; try { - unwrap(await window.agentCompany.saveSocialAccount({ - companyId: input.companyId, - platform: input.platform, - accountName: input.accountName, - displayName: input.displayName, - requireApproval: input.requireApproval, + const accountId = unwrap(await window.agentCompany.saveSocialAccount({ + companyId: snapshot.currentCompanyId, + platform, + accountName: platform, + displayName: "", + requireApproval, profileUrl: "", status: "login_required", metadataJson: "{}", })); await refresh(); + unwrap(await window.agentCompany.triggerBrowserLogin({ companyId: snapshot.currentCompanyId, socialAccountId: accountId })); setError(null); } catch (error) { setError(error instanceof Error ? error.message : String(error)); diff --git a/src/shared/connector-models.ts b/src/shared/connector-models.ts index cc1c513c..7e2339a3 100644 --- a/src/shared/connector-models.ts +++ b/src/shared/connector-models.ts @@ -35,12 +35,3 @@ export function mergeConnectorModelEntries( }); } -export function resolveSelectedConnectorModel( - entries: ConnectorModelEntry[], - currentModel: string, -): string { - if (currentModel && entries.some((entry) => entry.enabled && entry.id === currentModel)) { - return currentModel; - } - return entries.find((entry) => entry.enabled)?.id ?? ""; -} diff --git a/src/shared/contracts/schemas-approvals.ts b/src/shared/contracts/schemas-approvals.ts index 4311cda3..c5d84f5e 100644 --- a/src/shared/contracts/schemas-approvals.ts +++ b/src/shared/contracts/schemas-approvals.ts @@ -6,7 +6,7 @@ export const approvalInputSchema = z.object({ relatedTaskId: optionalId, requestedByAgentId: optionalId, relatedAgentId: optionalId, - type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation", "social_post", "deliverable_review"]), + type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation", "social_post", "deliverable_review", "budget_override", "plan_review", "scope_review", "draft_review", "strategy_review", "exception_review"]), payloadSummary: z.string().min(2).max(280), impactSummary: z.string().min(2).max(400), payloadJson: z.string().nullable().optional(), diff --git a/src/shared/contracts/schemas-company.ts b/src/shared/contracts/schemas-company.ts index 6f864a18..9cd63597 100644 --- a/src/shared/contracts/schemas-company.ts +++ b/src/shared/contracts/schemas-company.ts @@ -58,7 +58,7 @@ export const bootstrapOnboardingSchema = z.object({ export const socialAccountInputSchema = z.object({ id: optionalId, companyId: z.string().uuid(), - platform: z.enum(["twitter", "linkedin", "instagram", "facebook", "reddit", "youtube", "tiktok", "bluesky", "other"]), + platform: z.enum(["twitter", "linkedin", "instagram", "facebook", "reddit", "youtube", "tiktok", "bluesky", "discord", "threads", "pinterest", "telegram", "whatsapp", "wechat", "weibo", "xiaohongshu", "douyin", "bilibili", "zhihu", "other"]), accountName: z.string().min(1).max(120), displayName: z.string().max(120).default(""), profileUrl: z.string().max(500).default(""), diff --git a/src/shared/types/enums.ts b/src/shared/types/enums.ts index c4f4ddb0..e3239f78 100644 --- a/src/shared/types/enums.ts +++ b/src/shared/types/enums.ts @@ -115,7 +115,27 @@ export type BudgetAlertLevel = "warn" | "hard_stop"; export type SkillSourceType = "github" | "local_path" | "url" | "skills_sh" | "managed"; export type TrustLevel = "markdown_only" | "assets" | "scripts_executables"; -export type SocialPlatform = "twitter" | "linkedin" | "instagram" | "facebook" | "reddit" | "youtube" | "tiktok" | "bluesky" | "other"; +export type SocialPlatform = + | "twitter" + | "linkedin" + | "instagram" + | "facebook" + | "reddit" + | "youtube" + | "tiktok" + | "bluesky" + | "discord" + | "threads" + | "pinterest" + | "telegram" + | "whatsapp" + | "wechat" + | "weibo" + | "xiaohongshu" + | "douyin" + | "bilibili" + | "zhihu" + | "other"; export type SocialAccountStatus = "active" | "logged_in" | "login_required" | "suspended" | "paused"; export type BrowserActionType = "post" | "reply" | "like" | "follow" | "browse_feed" | "screenshot" | "navigate" | "search"; export type BrowserActionStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled" | "approval_required"; diff --git a/tests/unit/connector-models.test.ts b/tests/unit/connector-models.test.ts index 77949838..3ae1df80 100644 --- a/tests/unit/connector-models.test.ts +++ b/tests/unit/connector-models.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { mergeConnectorModelEntries, - resolveSelectedConnectorModel, } from "@shared/connector-models"; describe("connector model helpers", () => { @@ -21,17 +20,6 @@ describe("connector model helpers", () => { ]); }); - it("keeps the current selection only if it remains enabled", () => { - const entries = mergeConnectorModelEntries( - [{ id: "gpt-5.4", label: "GPT-5.4" }], - [{ id: "gpt-5.4-mini", label: "GPT-5.4 Mini" }], - ); - - expect(resolveSelectedConnectorModel(entries, "gpt-5.4")).toBe("gpt-5.4"); - expect(resolveSelectedConnectorModel(entries, "gpt-5.4-mini")).toBe("gpt-5.4"); - expect(resolveSelectedConnectorModel(entries, "")).toBe("gpt-5.4"); - }); - it("dedupes duplicate ids and keeps enabled entries ahead of disabled ones", () => { const entries = mergeConnectorModelEntries( [{ id: "gpt-5.4", label: "GPT-5.4" }], @@ -47,9 +35,4 @@ describe("connector model helpers", () => { ]); }); - it("returns an empty selection when all entries are disabled", () => { - const entries = mergeConnectorModelEntries([], [{ id: "gpt-5.4", label: "GPT-5.4" }]); - - expect(resolveSelectedConnectorModel(entries, "gpt-5.4")).toBe(""); - }); }); diff --git a/tests/unit/keyboard.test.ts b/tests/unit/keyboard.test.ts index 3b4821f1..90195651 100644 --- a/tests/unit/keyboard.test.ts +++ b/tests/unit/keyboard.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { useRovingTabIndex } from "@renderer/lib/keyboard"; +import { createRovingTabIndex as useRovingTabIndex } from "@renderer/lib/keyboard"; // Since vitest runs in Node (no DOM), we test the hook logic // by extracting and testing the pure callback functions directly.