update
This commit is contained in:
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
|
||||
@@ -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/
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
Generated
-46
@@ -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: {}
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(""),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
Reference in new issue
Block a user