This commit is contained in:
ZacharyZhang-NY committed 2026-04-04 17:01:26 -04:00
1 parent 67c757f481
commit b8dda3c8d0
77 files changed
+1577 -2808

No files matched your search

@@ -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
-3
View File
@@ -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/
+8 -5
View File
@@ -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"
}
}
+1 -1
View File
@@ -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"),
},
},
},
+34 -7
View File
@@ -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 (
<section id="download" className="py-24 lg:py-40 px-4 sm:px-6 lg:px-8 relative overflow-hidden">
<motion.div
@@ -44,7 +72,10 @@ export function DownloadSection() {
Install the Right Build for Your Machine
</h2>
<p className="text-[var(--text-on-cta-muted)] text-lg max-w-xl mx-auto mb-12 leading-relaxed">
Full release matrix hosted on our Cloudflare release lane. Version {releaseVersion}.
Version {releaseVersion}.
</p>
<p className="text-[var(--text-on-cta-faint)] text-sm max-w-xl mx-auto mb-12 leading-relaxed -mt-8">
Downloads: {downloadCount.toLocaleString("en-US")}
</p>
<div className="w-full mt-2 grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -60,20 +91,16 @@ export function DownloadSection() {
<p className="font-mono text-xs uppercase tracking-[0.25em] text-[var(--text-on-cta-faint)] mb-3">
{download.title}
</p>
<p className="text-[var(--text-on-cta-muted)] text-sm leading-relaxed mb-4">{download.note}</p>
<div className="space-y-2.5">
{download.assets.map((asset) => (
<a
key={asset.id}
href={asset.href}
href={getTrackedDownloadHref(asset.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between gap-3 rounded-xl border border-white/12 bg-black/10 px-4 py-3 text-sm text-[var(--text-on-cta)] transition-colors duration-200 hover:bg-black/18"
className="flex items-center rounded-xl border border-white/12 bg-black/10 px-4 py-3 text-sm text-[var(--text-on-cta)] transition-colors duration-200 hover:bg-black/18"
>
<span className="font-semibold tracking-tight">{asset.label}</span>
<span className="font-mono text-xs uppercase tracking-[0.18em] text-[var(--text-on-cta-faint)]">
{asset.note}
</span>
</a>
))}
</div>
+5 -20
View File
@@ -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() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4 text-xs text-[var(--muted)]">
<span>© {year} AgentCompany</span>
<span className="hidden sm:inline-flex items-center gap-1.5">
Cloudflare-hosted desktop builds
</span>
</div>
<div className="flex items-center gap-1">
+1 -1
View File
@@ -54,7 +54,7 @@ export function Hero() {
<div className="flex flex-col items-center text-center">
<motion.div {...heroMotion(0.05)}>
<span className="inline-flex items-center px-4 py-1.5 rounded-full text-xs font-semibold tracking-wide bg-[var(--accent-soft)] text-[var(--accent)] dark:text-[var(--muted-strong)] border border-[var(--border)]">
Local-First · Desktop App
Agent Company
</span>
</motion.div>
+10 -10
View File
@@ -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",
},
],
+34
View File
@@ -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<Env> = async (context) => {
const downloads = await readDownloadCount(context.env.DOWNLOAD_COUNTER_DB);
return Response.json(
{ downloads },
{
headers: {
"Cache-Control": "no-store",
},
},
);
};
+73
View File
@@ -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<Env> = 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);
};
+17
View File
@@ -0,0 +1,17 @@
export interface CounterDbStatement {
bind(...values: unknown[]): {
first<T = unknown>(): Promise<T | null>;
run(): Promise<unknown>;
};
}
export interface CounterDatabase {
prepare(query: string): CounterDbStatement;
}
export interface PagesHandlerContext<Env> {
request: Request;
env: Env;
}
export type PagesHandler<Env> = (context: PagesHandlerContext<Env>) => Response | Promise<Response>;
+28
View File
@@ -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)}`;
}
@@ -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);
+8
View File
@@ -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"
+2 -4
View File
@@ -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"
},
-50
View File
@@ -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"
}
}
@@ -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);
}
@@ -1 +0,0 @@
export { printKimiStreamEvent } from "./format-event.js";
-33
View File
@@ -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.
`;
@@ -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<void> {
if (managedSkills.length === 0) return;
await createManagedSkillSymlinks(managedSkills, kimiSkillsHome(), onLog);
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
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",
};
}
@@ -1,2 +0,0 @@
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
@@ -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<AdapterEnvironmentTestResult> {
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(),
};
}
}
@@ -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<string, unknown> {
const config: Record<string, unknown> = {};
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;
}
@@ -1,2 +0,0 @@
export { buildKimiLocalConfig } from "./build-config.js";
export { parseKimiStdoutLine } from "./parse-stdout.js";
@@ -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 }];
}
@@ -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([]);
});
});
@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
@@ -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"
}
}
@@ -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);
}
@@ -1 +0,0 @@
export { printOpenCodeStreamEvent } from "./format-event.js";
@@ -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.
`;
@@ -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<string | null> {
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<AdapterExecutionResult> {
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<string, unknown> => 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<string, string> = { ...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, `<stdin prompt ${prompt.length} chars>`],
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<typeof parseOpenCodeJsonl>;
},
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<string, unknown>)
: 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);
}
@@ -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";
@@ -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");
});
});
@@ -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<string, { expiresAt: number; models: AdapterModel[] }>();
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<string>();
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<string, string> {
const envInput = typeof input === "object" && input !== null && !Array.isArray(input)
? (input as Record<string, unknown>)
: {};
const env: Record<string, string> = {};
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<string, string>) {
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<AdapterModel[]> {
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<AdapterModel[]> {
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<AdapterModel[]> {
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<AdapterModel[]> {
try {
return await discoverOpenCodeModelsCached();
} catch {
return [];
}
}
export function resetOpenCodeModelsCacheForTests() {
discoveryCache.clear();
}
@@ -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);
});
});
@@ -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,
);
}
@@ -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<AdapterEnvironmentTestResult> {
// 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<AdapterEnvironmentTestResult> {
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<string, string> = {};
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(),
};
}
@@ -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<string, unknown> {
const ac: Record<string, unknown> = {};
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;
}
@@ -1,2 +0,0 @@
export { parseOpenCodeStdoutLine } from "./parse-stdout.js";
export { buildOpenCodeLocalConfig } from "./build-config.js";
@@ -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<string, unknown>, 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 }];
}
@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
@@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});
-46
View File
@@ -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: {}
+550
View File
@@ -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();
+76 -27
View File
@@ -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<string, unknown>);
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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();
+3 -3
View File
@@ -67,7 +67,7 @@ export class AgentApiServer {
private emitEventCb: (event: DesktopEvent) => void;
private callbacks: ApiServerEventCallbacks;
private allowedOrigins: Set<string>;
private onBrowserActionCb: ((actionId: string, companyId: string, socialAccountId: string, actionType: string, payload: Record<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => void) | null {
get onBrowserAction_(): ((actionId: string, companyId: string) => void) | null {
return this.onBrowserActionCb;
}
get port_(): number { return this.port; }
+5
View File
@@ -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,
};
}
+27
View File
@@ -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();
+77
View File
@@ -85,6 +85,83 @@ export const PLATFORM_RULES: Record<string, PlatformRule> = {
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",
+28
View File
@@ -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}`;
}
+4 -6
View File
@@ -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)}`;
}
+46 -250
View File
@@ -24,6 +24,51 @@ export interface ConnectorDefinition {
listModels?: () => Promise<Array<{ id: string; label: string }>>;
}
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",
+12 -3
View File
@@ -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",
+5 -7
View File
@@ -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<T>(data: T): DesktopResult<T> {
return { ok: true, data };
}
export function fail(code: string, message: string): DesktopResult<never> {
return { ok: false, error: { code, message } };
}
export { ok, fail } from "./ipc-core-common";
-129
View File
@@ -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<string>();
const toolResultIds = new Set<string>();
// 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<string>();
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<string>();
const toolResultIds = new Set<string>();
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;
+1 -1
View File
@@ -55,7 +55,7 @@ const RUBRICS: Record<TaskType, TaskRubric> = {
};
/** 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;
}
@@ -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<ThreadIntegrationDeps, "emitEvent" | "publishDomainChanged">,
): 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 {
@@ -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<ThreadIntegrationDeps, "emitEvent" | "publishDomainChanged">,
): 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;
}
}
File renamed without changes.
+16 -11
View File
@@ -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<typeof db["getRun"]>;
try {
run = db.getRun(event.runId);
} catch {
return;
}
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/72 backdrop-blur-sm" onClick={onClose}>
<div ref={trapRef} role="dialog" aria-modal="true" aria-label={currentLocale === "zh" ? "Claude Code 设置" : "Claude Code Settings"} className="w-full max-w-[min(420px,90vw)] rounded-lg border border-border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
<div className="mb-5 flex items-center justify-between">
<h3 className="text-[16px] font-semibold text-foreground">Claude Code {currentLocale === "zh" ? "设置" : "Settings"}</h3>
<button type="button" onClick={onClose} className="rounded-lg p-1 text-muted-foreground transition hover:text-foreground"><X size={16} /></button>
</div>
<div className="space-y-4">
<div>
<label htmlFor="claude-api-key" className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">API Key</label>
<input
id="claude-api-key"
type="password"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"
value={claudeApiKey}
onChange={(e) => setClaudeApiKey(e.target.value)}
placeholder="sk-ant-..."
/>
<div className="mt-1 text-[11px] text-muted-foreground">{currentLocale === "zh" ? "留空使用订阅认证" : "Leave empty to use subscription auth"}</div>
</div>
<div>
<label htmlFor="claude-base-url" className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Base URL</label>
<input
id="claude-base-url"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"
value={claudeBaseUrl}
onChange={(e) => setClaudeBaseUrl(e.target.value)}
placeholder="https://api.anthropic.com"
/>
<div className="mt-1 text-[11px] text-muted-foreground">{currentLocale === "zh" ? "留空使用默认端点" : "Leave empty for default endpoint"}</div>
</div>
<div>
<label htmlFor="claude-model" className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Model</label>
<input
id="claude-model"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"
value={claudeModel}
onChange={(e) => setClaudeModel(e.target.value)}
placeholder="claude-sonnet-4-20250514"
/>
<div className="mt-1 text-[11px] text-muted-foreground">{currentLocale === "zh" ? "留空使用连接器默认模型" : "Leave empty for connector default"}</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button type="button" onClick={onClose} className="rounded-lg px-4 py-2 text-[13px] font-medium text-muted-foreground transition hover:text-foreground">
{currentLocale === "zh" ? "取消" : "Cancel"}
</button>
<button
type="button"
onClick={onSave}
disabled={saving}
className="rounded-lg bg-primary px-4 py-2 text-[13px] font-medium text-primary-foreground transition hover:opacity-90 disabled:opacity-50"
>
{saving ? (currentLocale === "zh" ? "保存中..." : "Saving...") : (currentLocale === "zh" ? "保存" : "Save")}
</button>
</div>
</div>
</div>
);
}
@@ -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 (
<div className="mt-10">
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{t("onboarding.connectorReadiness")}
</div>
<div className="space-y-2.5">
{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 (
<div key={connector.id} className="flex items-start gap-2.5 text-[13px]">
{isChecking ? (
<span className="mt-0.5 h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-[color:var(--muted-foreground)] border-t-[color:var(--primary)]" />
) : isReady ? (
<CheckCircle size={16} weight="fill" className="shrink-0 text-success" />
) : isMissing ? (
<XCircle size={16} weight="fill" className="shrink-0 text-muted-foreground" />
) : (
<WarningCircle size={16} weight="fill" className="shrink-0 text-warning" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={isReady ? "font-medium text-foreground" : isChecking ? "text-foreground" : "text-muted-foreground"}>
{connector.label}
</span>
<span className="text-[11px] text-muted-foreground">
{isChecking ? (currentLocale === "zh" ? "检测中..." : "checking...") : connector.status.replaceAll("_", " ")}
</span>
</div>
{issue ? (
<div className="mt-0.5 text-[11px] leading-relaxed text-muted-foreground">
{issue}
</div>
) : null}
</div>
{connector.id === "claude_local" ? (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onOpenClaudeSettings(); }}
className="shrink-0 rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted hover:text-foreground"
title={currentLocale === "zh" ? "Claude Code 设置" : "Claude Code Settings"}
>
<GearSix size={14} />
</button>
) : null}
</div>
);
})}
</div>
{!hasRunnableConnector ? (
<div className="mt-4 rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-3 text-[12px] text-warning">
{t("onboarding.connectorWarning")}
</div>
) : null}
</div>
);
}
@@ -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 (
<motion.div
key="language"
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -18 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
className="bg-card border border-border shadow-sm w-full rounded-lg p-7"
>
<div className="mb-2 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
<GlobeSimple size={12} />
{t("onboarding.languageSelection")} / Language
</div>
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
{t("onboarding.selectLanguage")} / Select Language
</div>
<div className="mb-6 h-px bg-[color:var(--border)]" />
<div className="space-y-2.5">
{APP_LOCALES.map((locale) => (
<button
key={locale}
type="button"
onClick={() => onLocaleChange(locale)}
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3.5 text-left transition ${
currentLocale === locale
? "border-[color:var(--primary)] bg-accent"
: "border-border bg-muted hover:border-[color:var(--muted-foreground)]"
}`}
>
<div className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border ${
currentLocale === locale
? "border-[color:var(--primary)] bg-primary"
: "border-border"
}`}>
{currentLocale === locale ? <CheckCircle size={14} weight="fill" className="text-primary-foreground" /> : null}
</div>
<span className={`text-[14px] font-medium ${
currentLocale === locale ? "text-foreground" : "text-muted-foreground"
}`}>
{LOCALE_LABELS[locale]}
</span>
</button>
))}
</div>
<div className="mt-8 flex items-center justify-end">
<ActionButton
label={t("onboarding.continue")}
onClick={onContinue}
icon={GlobeSimple}
tone="accent"
/>
</div>
</motion.div>
);
}
@@ -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<SocialPlatform>("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) {
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Platform</label>
<div className="grid grid-cols-3 gap-1.5">
{(Object.entries(platformConfig) as [SocialPlatform, typeof platformConfig.twitter][]).map(([key, config]) => (
<button
key={key}
type="button"
onClick={() => setPlatform(key)}
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-[11px] font-medium transition ${
platform === key
? "border-[color:var(--primary)] bg-accent text-primary"
: "border-border text-muted-foreground hover:border-border"
}`}
>
<span style={{ color: config.color }}>
<config.icon size={14} />
</span>
{config.label}
</button>
))}
<div className="max-h-[240px] overflow-y-auto rounded-lg border border-border p-1.5">
<div className="grid grid-cols-3 gap-1.5">
{(Object.entries(platformConfig) as [SocialPlatform, typeof platformConfig.twitter][]).map(([key, config]) => (
<button
key={key}
type="button"
onClick={() => setPlatform(key)}
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-[11px] font-medium transition ${
platform === key
? "border-[color:var(--primary)] bg-accent text-primary"
: "border-border text-muted-foreground hover:border-border"
}`}
>
<span style={{ color: config.color }}>
<config.icon size={14} />
</span>
<span className="truncate">{config.label}</span>
</button>
))}
</div>
</div>
</div>
<Input
label="Username / Handle"
value={accountName}
onChange={setAccountName}
placeholder={platform === "twitter" ? "elonmusk" : "your-username"}
/>
<Input
label="Display Name (optional)"
value={displayName}
onChange={setDisplayName}
placeholder="Your display name"
/>
<div className="rounded-lg border border-border bg-muted px-4 py-3">
<label className="flex items-center gap-3">
<input
@@ -92,6 +78,10 @@ export function AddAccountDialog({ onAdd, onClose }: AddAccountDialogProps) {
</div>
</label>
</div>
<div className="rounded-lg bg-accent px-4 py-3 text-[11px] text-muted-foreground">
A browser window will open for you to sign in. Your username and profile info will be detected automatically after login.
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
@@ -103,15 +93,10 @@ export function AddAccountDialog({ onAdd, onClose }: AddAccountDialogProps) {
Cancel
</button>
<ActionButton
label="Add Account"
onClick={() => {
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()}
/>
</div>
</motion.div>
+7 -13
View File
@@ -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<void>;
onAddAndLogin: (platform: SocialPlatform, requireApproval: boolean) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onLogin: (accountId: string) => Promise<void>;
onOpenPath: (path: string) => Promise<void>;
@@ -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 (
<div className="space-y-5">
@@ -151,7 +145,7 @@ export function SocialAccountsPanel({
<AnimatePresence>
{showAddDialog ? (
<AddAccountDialog
onAdd={(platform, accountName, displayName, requireApproval) => void handleAdd(platform, accountName, displayName, requireApproval)}
onAddAndLogin={(platform, requireApproval) => void handleAddAndLogin(platform, requireApproval)}
onClose={() => setShowAddDialog(false)}
/>
) : null}
@@ -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<SocialPlatform, { label: string; color: stri
youtube: { label: "YouTube", color: "#FF0000", bgColor: "#FF000014", icon: YoutubeLogo },
tiktok: { label: "TikTok", color: "#111315", bgColor: "#11131514", icon: TiktokLogo },
bluesky: { label: "Bluesky", color: "#0085FF", bgColor: "#0085FF14", icon: GlobeHemisphereWest },
discord: { label: "Discord", color: "#5865F2", bgColor: "#5865F214", icon: DiscordLogo },
threads: { label: "Threads", color: "#000000", bgColor: "#00000014", icon: GlobeHemisphereWest },
pinterest: { label: "Pinterest", color: "#E60023", bgColor: "#E6002314", icon: PinterestLogo },
telegram: { label: "Telegram", color: "#26A5E4", bgColor: "#26A5E414", icon: TelegramLogo },
whatsapp: { label: "WhatsApp", color: "#25D366", bgColor: "#25D36614", icon: WhatsappLogo },
wechat: { label: "微信 WeChat", color: "#07C160", bgColor: "#07C16014", icon: ChatTeardrop },
weibo: { label: "微博 Weibo", color: "#E6162D", bgColor: "#E6162D14", icon: Globe },
xiaohongshu: { label: "小红书 RED", color: "#FE2C55", bgColor: "#FE2C5514", icon: Notebook },
douyin: { label: "抖音 Douyin", color: "#161823", bgColor: "#16182314", icon: MusicNote },
bilibili: { label: "哔哩哔哩 Bilibili", color: "#00A1D6", bgColor: "#00A1D614", icon: MonitorPlay },
zhihu: { label: "知乎 Zhihu", color: "#0066FF", bgColor: "#0066FF14", icon: Lightbulb },
other: { label: "Other", color: "#6B7280", bgColor: "#6B728014", icon: GlobeHemisphereWest },
};
@@ -41,6 +60,4 @@ export const actionTypeLabels: Record<string, { label: string; icon: React.Eleme
screenshot: { label: "Screenshot", icon: Camera },
navigate: { label: "Navigate", icon: Globe },
search: { label: "Search", icon: MagnifyingGlass },
dm: { label: "Direct Message", icon: ChatCircle },
custom_script: { label: "Legacy Action", icon: Browser },
};
+2 -2
View File
@@ -40,13 +40,13 @@ const sidebarNavDefs: NavItemDef[] = [
{ id: "approvals", labelKey: "nav.approvals", icon: ShieldCheck },
{ id: "agents", labelKey: "nav.agents", icon: UsersThree },
{ id: "workspaces", labelKey: "nav.workspaces", icon: FolderOpen },
{ id: "knowledge", labelKey: "nav.knowledge", icon: BookOpen },
{ id: "social", labelKey: "nav.social", icon: Megaphone },
{ id: "documents", labelKey: "nav.documents", icon: FileText },
];
const paletteOnlyNavDefs: NavItemDef[] = [
{ id: "skills", labelKey: "nav.skills", icon: PuzzlePiece },
{ id: "social", labelKey: "nav.social", icon: Megaphone },
{ id: "knowledge", labelKey: "nav.knowledge", icon: BookOpen },
];
export function useAppController() {
-56
View File
@@ -1,56 +0,0 @@
/**
* Company Context Provider
*
* Provides shared company infrastructure to section components, eliminating
* the need to thread 80+ props through SectionContentView. Components can
* import useCompanyContext() instead of receiving props from the root.
*
* This context holds stable references to company data and actions that
* nearly every section needs: companyId, snapshot, refresh, navigation, etc.
*/
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { CompanyRecord, ProfileSnapshot, SectionId } from "@shared/types";
export interface CompanyContextValue {
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<void>;
}
const CompanyContext = createContext<CompanyContextValue | null>(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<void>;
children: ReactNode;
}
export function CompanyContextProvider({
companyId, currentCompany, snapshot, refresh,
navigateTo, setSection, setError, openPath, children,
}: CompanyContextProviderProps) {
const value = useMemo<CompanyContextValue>(() => ({
companyId, currentCompany, snapshot, refresh,
navigateTo, setSection, setError, openPath,
}), [companyId, currentCompany, snapshot, refresh, navigateTo, setSection, setError, openPath]);
return <CompanyContext.Provider value={value}>{children}</CompanyContext.Provider>;
}
export function useCompanyContext(): CompanyContextValue {
const ctx = useContext(CompanyContext);
if (!ctx) throw new Error("useCompanyContext must be used within CompanyContextProvider");
return ctx;
}
+61 -6
View File
@@ -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<HTMLDivElement | null>(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,
};
}
@@ -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));
-9
View File
@@ -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 ?? "";
}
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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(""),
+21 -1
View File
@@ -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";
-17
View File
@@ -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("");
});
});
+1 -1
View File
@@ -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.