large update

This commit is contained in:
ZacharyZhang-NY committed 2026-03-29 14:08:15 -04:00
1 parent 2ed952f19c
commit 65e85135c1
111 files changed
+3936 -2933

No files matched your search

+3 -3
View File
@@ -9,6 +9,8 @@ coverage/
*.tsbuildinfo
win-unpacked
.blockmap
.claude
build
# Test artifacts
test-results/
@@ -58,8 +60,6 @@ npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
CLAUDE.md
build/release/AgentCompany-0.4.61-arm64.dmg
CLAUDE.md
CLAUDE.md
build/release/AgentCompany-0.4.90-arm64.dmg
build/release/AgentCompany-0.4.90-arm64.dmg
+35
View File
@@ -457,3 +457,38 @@ AgentCompany is a cross-platform Electron desktop app that lets users create aut
- Async cleanup: `appLifecycleService` waits 3 seconds for worker child process to exit before app.exit(0)
## Cross-Cutting Concerns
<!-- GSD:architecture-end -->
## 核心原则 (Core Persona)
1. **​第一性原理​**: 从原始需求出发。动机不清立刻停,路径非最优直接纠正。
2. **​极简沟通​**: 用简单直白的中文一次性输出,把用户当高中生。拒绝角色扮演,拒绝分段分口吻,对话中已解决的问题后续绝不再提。不要用 P0/P1/P2 这种术语。
3. **​Let it crash​**: 发现问题尽早暴露。严禁使用任何降级、兜底、后发式补丁或非严谨通用算法的后处理补救。
4. **​禁止擅自开分支​**: 严禁私自创建新 worktree。可以给建议,但必须征得用户明确同意后方可操作。
5. **​自检与精简​**: 每次改动后,严格执行「Review查Bug然后第一性原理分析」流程,思考是否有更简单、更稳健的实现。
## 开发工作流 (Development Workflow)
1. **​分析层​**: 文字、图标、颜色的UI修改,直接操作执行层并落地archive。重大重构/多任务才走规划层。
2. **​规划层​**: 使用 using-superpowers 编排流程并产出/更新全局流程图。
3. **​任务层​**: 使用 planning-with-files 维护 task_plan.md / progress.md / findings.md。
4. **​执行层​**: OpenSpec 四步闭环(propose -> 用户确认 -> apply -> archive)。
5. **​粒度控制​**: 动手前用 gsd-method-guide 拆解为 `<files>/<action>/<verify>/<done>`。
## 工程规范 (Engineering Constraints)
1. **​数据处理​**: 不可捏造数据。生产代码严禁 Mock。Mock 仅限本地调试(统一入口: `127.0.0.1:xxxx/mock`),必须在 `.gitignore` 中排除。
2. **​自动化执行​**: `curl`、`cat`、`git` 等命令直接运行免确认;Playwright 脚本在终端持续会话,禁止无意义的暂停。
3. **​子代理分流​**: 复杂问题(多于1个、需 Review/研究/并行分析)必须拆解并使用子代理(5.4 模型),保持主上下文纯净。
4. **​API 接入​**: 参考 `/Users/ifengz/CodingCase/002_DevelopSpec/1.Doc/`中的现成方案,并不断补充新内容。
5. **​自我进化​**: 用户指正后立即更新 `lessons.md`。开始新任务前必须回顾 `lessons.md`。
## 运维安全守则 (Operations Constraints)
1. **​排障顺序​**: 遇网络/证书/代理异常,优先排查入口及反代配置。严禁使用临时IP:端口判定数据库损坏,必须寻找固定入口(域名/面板地址)。
## 输出规范 (Output Specs - 拒绝啰嗦)
1. **​禁止陈述式汇报​**: 严禁复读背景,严禁分“证据/分析/结论”等多维度拆解简单问题。
2. **​结论先行​**: 直接给结论和修补方案。解释必须是短小精悍的中文大白话,不显示P0/P1等级。
3. **​表格化输出​**: 多数内容(尤其是评审、对比、多项任务)必须以Markdown表格输出。
4. **​强制收口​**: 结束对话必须明确告知用到的 skill。
+3 -3
View File
@@ -1,9 +1,9 @@
{
"extends": "C:\\Users\\zhang\\Documents\\GitHub\\AgentCompany\\.claude\\worktrees\\agent-afbb9437\\electron-builder.yml",
"extends": "C:\\Users\\zhang\\Documents\\GitHub\\AgentCompany\\electron-builder.yml",
"directories": {
"output": "C:\\Users\\zhang\\Documents\\GitHub\\AgentCompany\\.claude\\worktrees\\agent-afbb9437\\build\\release"
"output": "C:\\Users\\zhang\\Documents\\GitHub\\AgentCompany\\build\\release"
},
"extraMetadata": {
"version": "0.4.100"
"version": "0.4.133"
}
}
+1 -1
View File
@@ -43,4 +43,4 @@ arm64:
- assets/icon.png
- assets/tray-icon*.png
nsis:
script: "!include \"C:\\Users\\zhang\\Documents\\GitHub\\AgentCompany\\.claude\\worktrees\\agent-afbb9437\\node_modules\\.pnpm\\app-builder-lib@26.8.1_dmg-_62d31da0b96a3ea7b7d51a6bbe3b3b6e\\node_modules\\app-builder-lib\\templates\\nsis\\include\\StdUtils.nsh\"\n!addincludedir \"C:\\Users\\zhang\\Documents\\GitHub\\AgentCompany\\.claude\\worktrees\\agent-afbb9437\\node_modules\\.pnpm\\app-builder-lib@26.8.1_dmg-_62d31da0b96a3ea7b7d51a6bbe3b3b6e\\node_modules\\app-builder-lib\\templates\\nsis\\include\"\n!macro _isUpdated _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"updated\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isUpdated `\"\" isUpdated \"\"`\n\n!macro _isForceRun _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"force-run\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isForceRun `\"\" isForceRun \"\"`\n\n!macro _isKeepShortcuts _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"keep-shortcuts\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isKeepShortcuts `\"\" isKeepShortcuts \"\"`\n\n!macro _isNoDesktopShortcut _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"no-desktop-shortcut\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isNoDesktopShortcut `\"\" isNoDesktopShortcut \"\"`\n\n!macro _isDeleteAppData _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"delete-app-data\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isDeleteAppData `\"\" isDeleteAppData \"\"`\n\n!macro _isForAllUsers _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"allusers\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isForAllUsers `\"\" isForAllUsers \"\"`\n\n!macro _isForCurrentUser _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"currentuser\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isForCurrentUser `\"\" isForCurrentUser \"\"`\n\n!macro addLangs\n !insertmacro MUI_LANGUAGE \"English\"\n !insertmacro MUI_LANGUAGE \"German\"\n !insertmacro MUI_LANGUAGE \"French\"\n !insertmacro MUI_LANGUAGE \"SpanishInternational\"\n !insertmacro MUI_LANGUAGE \"SimpChinese\"\n !insertmacro MUI_LANGUAGE \"TradChinese\"\n !insertmacro MUI_LANGUAGE \"Japanese\"\n !insertmacro MUI_LANGUAGE \"Korean\"\n !insertmacro MUI_LANGUAGE \"Italian\"\n !insertmacro MUI_LANGUAGE \"Dutch\"\n !insertmacro MUI_LANGUAGE \"Danish\"\n !insertmacro MUI_LANGUAGE \"Swedish\"\n !insertmacro MUI_LANGUAGE \"Norwegian\"\n !insertmacro MUI_LANGUAGE \"Finnish\"\n !insertmacro MUI_LANGUAGE \"Russian\"\n !insertmacro MUI_LANGUAGE \"Portuguese\"\n !insertmacro MUI_LANGUAGE \"PortugueseBR\"\n !insertmacro MUI_LANGUAGE \"Polish\"\n !insertmacro MUI_LANGUAGE \"Ukrainian\"\n !insertmacro MUI_LANGUAGE \"Czech\"\n !insertmacro MUI_LANGUAGE \"Slovak\"\n !insertmacro MUI_LANGUAGE \"Hungarian\"\n !insertmacro MUI_LANGUAGE \"Arabic\"\n !insertmacro MUI_LANGUAGE \"Turkish\"\n !insertmacro MUI_LANGUAGE \"Thai\"\n !insertmacro MUI_LANGUAGE \"Vietnamese\"\n!macroend\n\n!addplugindir /x86-unicode \"C:\\Users\\zhang\\AppData\\Local\\electron-builder\\Cache\\nsis\\nsis-resources-3.4.1-nsis-resources-3.4.1\\plugins\\x86-unicode\"\n!include \"C:\\Users\\zhang\\AppData\\Local\\Temp\\t-8uQAoB\\6-messages.nsh\"\n\n!include \"common.nsh\"\n!include \"extractAppPackage.nsh\"\n\n# https://github.com/electron-userland/electron-builder/issues/3972#issuecomment-505171582\nCRCCheck off\nWindowIcon Off\nAutoCloseWindow True\nRequestExecutionLevel ${REQUEST_EXECUTION_LEVEL}\n\nFunction .onInit\n !ifndef SPLASH_IMAGE\n SetSilent silent\n !endif\n\n !insertmacro check64BitAndSetRegView\nFunctionEnd\n\nFunction .onGUIInit\n InitPluginsDir\n\n !ifdef SPLASH_IMAGE\n File /oname=$PLUGINSDIR\\splash.bmp \"${SPLASH_IMAGE}\"\n BgImage::SetBg $PLUGINSDIR\\splash.bmp\n BgImage::Redraw\n !endif\nFunctionEnd\n\nSection\n !ifdef SPLASH_IMAGE\n HideWindow\n !endif\n\n StrCpy $INSTDIR \"$PLUGINSDIR\\app\"\n !ifdef UNPACK_DIR_NAME\n StrCpy $INSTDIR \"$TEMP\\${UNPACK_DIR_NAME}\"\n !endif\n\n RMDir /r $INSTDIR\n SetOutPath $INSTDIR\n\n !ifdef APP_DIR_64\n !ifdef APP_DIR_ARM64\n !ifdef APP_DIR_32\n ${if} ${IsNativeARM64}\n File /r \"${APP_DIR_ARM64}\\*.*\"\n ${elseif} ${RunningX64}\n File /r \"${APP_DIR_64}\\*.*\"\n ${else}\n File /r \"${APP_DIR_32}\\*.*\"\n ${endIf}\n !else\n ${if} ${IsNativeARM64}\n File /r \"${APP_DIR_ARM64}\\*.*\"\n ${else}\n File /r \"${APP_DIR_64}\\*.*\"\n {endIf}\n !endif\n !else\n !ifdef APP_DIR_32\n ${if} ${RunningX64}\n File /r \"${APP_DIR_64}\\*.*\"\n ${else}\n File /r \"${APP_DIR_32}\\*.*\"\n ${endIf}\n !else\n File /r \"${APP_DIR_64}\\*.*\"\n !endif\n !endif\n !else\n !ifdef APP_DIR_32\n File /r \"${APP_DIR_32}\\*.*\"\n !else\n !insertmacro extractEmbeddedAppPackage\n !endif\n !endif\n\n System::Call 'Kernel32::SetEnvironmentVarLine truncated
script: "!include \"C:\\Users\\zhang\\Documents\\GitHub\\AgentCompany\\node_modules\\.pnpm\\app-builder-lib@26.8.1_dmg-_62d31da0b96a3ea7b7d51a6bbe3b3b6e\\node_modules\\app-builder-lib\\templates\\nsis\\include\\StdUtils.nsh\"\n!addincludedir \"C:\\Users\\zhang\\Documents\\GitHub\\AgentCompany\\node_modules\\.pnpm\\app-builder-lib@26.8.1_dmg-_62d31da0b96a3ea7b7d51a6bbe3b3b6e\\node_modules\\app-builder-lib\\templates\\nsis\\include\"\n!macro _isUpdated _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"updated\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isUpdated `\"\" isUpdated \"\"`\n\n!macro _isForceRun _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"force-run\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isForceRun `\"\" isForceRun \"\"`\n\n!macro _isKeepShortcuts _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"keep-shortcuts\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isKeepShortcuts `\"\" isKeepShortcuts \"\"`\n\n!macro _isNoDesktopShortcut _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"no-desktop-shortcut\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isNoDesktopShortcut `\"\" isNoDesktopShortcut \"\"`\n\n!macro _isDeleteAppData _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"delete-app-data\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isDeleteAppData `\"\" isDeleteAppData \"\"`\n\n!macro _isForAllUsers _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"allusers\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isForAllUsers `\"\" isForAllUsers \"\"`\n\n!macro _isForCurrentUser _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"currentuser\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isForCurrentUser `\"\" isForCurrentUser \"\"`\n\n!macro addLangs\n !insertmacro MUI_LANGUAGE \"English\"\n !insertmacro MUI_LANGUAGE \"German\"\n !insertmacro MUI_LANGUAGE \"French\"\n !insertmacro MUI_LANGUAGE \"SpanishInternational\"\n !insertmacro MUI_LANGUAGE \"SimpChinese\"\n !insertmacro MUI_LANGUAGE \"TradChinese\"\n !insertmacro MUI_LANGUAGE \"Japanese\"\n !insertmacro MUI_LANGUAGE \"Korean\"\n !insertmacro MUI_LANGUAGE \"Italian\"\n !insertmacro MUI_LANGUAGE \"Dutch\"\n !insertmacro MUI_LANGUAGE \"Danish\"\n !insertmacro MUI_LANGUAGE \"Swedish\"\n !insertmacro MUI_LANGUAGE \"Norwegian\"\n !insertmacro MUI_LANGUAGE \"Finnish\"\n !insertmacro MUI_LANGUAGE \"Russian\"\n !insertmacro MUI_LANGUAGE \"Portuguese\"\n !insertmacro MUI_LANGUAGE \"PortugueseBR\"\n !insertmacro MUI_LANGUAGE \"Polish\"\n !insertmacro MUI_LANGUAGE \"Ukrainian\"\n !insertmacro MUI_LANGUAGE \"Czech\"\n !insertmacro MUI_LANGUAGE \"Slovak\"\n !insertmacro MUI_LANGUAGE \"Hungarian\"\n !insertmacro MUI_LANGUAGE \"Arabic\"\n !insertmacro MUI_LANGUAGE \"Turkish\"\n !insertmacro MUI_LANGUAGE \"Thai\"\n !insertmacro MUI_LANGUAGE \"Vietnamese\"\n!macroend\n\n!addplugindir /x86-unicode \"C:\\Users\\zhang\\AppData\\Local\\electron-builder\\Cache\\nsis\\nsis-resources-3.4.1-nsis-resources-3.4.1\\plugins\\x86-unicode\"\n!include \"C:\\Users\\zhang\\AppData\\Local\\Temp\\t-GU2sm0\\6-messages.nsh\"\n\n!include \"common.nsh\"\n!include \"extractAppPackage.nsh\"\n\n# https://github.com/electron-userland/electron-builder/issues/3972#issuecomment-505171582\nCRCCheck off\nWindowIcon Off\nAutoCloseWindow True\nRequestExecutionLevel ${REQUEST_EXECUTION_LEVEL}\n\nFunction .onInit\n !ifndef SPLASH_IMAGE\n SetSilent silent\n !endif\n\n !insertmacro check64BitAndSetRegView\nFunctionEnd\n\nFunction .onGUIInit\n InitPluginsDir\n\n !ifdef SPLASH_IMAGE\n File /oname=$PLUGINSDIR\\splash.bmp \"${SPLASH_IMAGE}\"\n BgImage::SetBg $PLUGINSDIR\\splash.bmp\n BgImage::Redraw\n !endif\nFunctionEnd\n\nSection\n !ifdef SPLASH_IMAGE\n HideWindow\n !endif\n\n StrCpy $INSTDIR \"$PLUGINSDIR\\app\"\n !ifdef UNPACK_DIR_NAME\n StrCpy $INSTDIR \"$TEMP\\${UNPACK_DIR_NAME}\"\n !endif\n\n RMDir /r $INSTDIR\n SetOutPath $INSTDIR\n\n !ifdef APP_DIR_64\n !ifdef APP_DIR_ARM64\n !ifdef APP_DIR_32\n ${if} ${IsNativeARM64}\n File /r \"${APP_DIR_ARM64}\\*.*\"\n ${elseif} ${RunningX64}\n File /r \"${APP_DIR_64}\\*.*\"\n ${else}\n File /r \"${APP_DIR_32}\\*.*\"\n ${endIf}\n !else\n ${if} ${IsNativeARM64}\n File /r \"${APP_DIR_ARM64}\\*.*\"\n ${else}\n File /r \"${APP_DIR_64}\\*.*\"\n {endIf}\n !endif\n !else\n !ifdef APP_DIR_32\n ${if} ${RunningX64}\n File /r \"${APP_DIR_64}\\*.*\"\n ${else}\n File /r \"${APP_DIR_32}\\*.*\"\n ${endIf}\n !else\n File /r \"${APP_DIR_64}\\*.*\"\n !endif\n !endif\n !else\n !ifdef APP_DIR_32\n File /r \"${APP_DIR_32}\\*.*\"\n !else\n !insertmacro extractEmbeddedAppPackage\n !endif\n !endif\n\n System::Call 'Kernel32::SetEnvironmentVariable(t, t)i (\"PORTABLE_EXECUTABLE_DIR\", \"$EXEDIR\").r0'\n System::CLine truncated
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-company",
"version": "0.4.100",
"version": "0.4.133",
"private": true,
"type": "module",
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
@@ -1,21 +1,5 @@
import pc from "picocolors";
function asErrorText(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value !== "object" || value === null || Array.isArray(value)) return "";
const obj = value as Record<string, unknown>;
const message =
(typeof obj.message === "string" && obj.message) ||
(typeof obj.error === "string" && obj.error) ||
(typeof obj.code === "string" && obj.code) ||
"";
if (message) return message;
try {
return JSON.stringify(obj);
} catch {
return "";
}
}
import { errorText as asErrorText } from "@agentcompany/adapter-utils/parse-utils";
export function printClaudeStreamEvent(raw: string, debug: boolean): void {
const line = raw.trim();
@@ -7,54 +7,6 @@ export {
isClaudeUnknownSessionError,
} from "./parse.js";
export { listClaudeSkills, syncClaudeSkills } from "./skills.js";
import type { AdapterSessionCodec } from "@agentcompany/adapter-utils";
import { createStandardSessionCodec } from "@agentcompany/adapter-utils/session-codec";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
},
};
export const sessionCodec = createStandardSessionCodec();
@@ -14,41 +14,9 @@ import {
ensurePathInEnv,
runChildProcess,
} from "@agentcompany/adapter-utils/server-utils";
import path from "node:path";
import { summarizeStatus, isNonEmpty, commandLooksLike, summarizeProbeDetail } from "@agentcompany/adapter-utils/test-utils";
import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function commandLooksLike(command: string, expected: string): boolean {
const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
}
function summarizeProbeDetail(stdout: string, stderr: string): string | null {
const raw = firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
const clean = raw.replace(/\s+/g, " ").trim();
const max = 240;
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
@@ -1,66 +1,5 @@
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
import { parseCommaArgs, parseEnvVars, parseEnvBindings, parseJsonObject } from "@agentcompany/adapter-utils/config-utils";
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
@@ -1,39 +1,10 @@
import type { TranscriptEntry } from "@agentcompany/adapter-utils";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
import { asRecord, safeJsonParse, errorText } from "@agentcompany/adapter-utils/parse-utils";
function asNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = asRecord(value);
if (!rec) return "";
const msg =
(typeof rec.message === "string" && rec.message) ||
(typeof rec.error === "string" && rec.error) ||
(typeof rec.code === "string" && rec.code) ||
"";
if (msg) return msg;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
@@ -1,34 +1,5 @@
import pc from "picocolors";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
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 msg =
(typeof rec.message === "string" && rec.message) ||
(typeof rec.error === "string" && rec.error) ||
(typeof rec.code === "string" && rec.code) ||
"";
if (msg) return msg;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
import { asRecord, errorText, asString, asNumber } from "@agentcompany/adapter-utils/parse-utils";
function printItemStarted(item: Record<string, unknown>): boolean {
const itemType = asString(item.type);
@@ -19,6 +19,7 @@ import {
runChildProcess,
createManagedSkillSymlinks,
} from "@agentcompany/adapter-utils/server-utils";
import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -44,15 +45,6 @@ function stripCodexRolloutNoise(text: string): string {
return kept.join("\n");
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
@@ -2,54 +2,6 @@ export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
export { listCodexSkills, syncCodexSkills } from "./skills.js";
import type { AdapterSessionCodec } from "@agentcompany/adapter-utils";
import { createStandardSessionCodec } from "@agentcompany/adapter-utils/session-codec";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
},
};
export const sessionCodec = createStandardSessionCodec();
@@ -13,41 +13,9 @@ import {
ensurePathInEnv,
runChildProcess,
} from "@agentcompany/adapter-utils/server-utils";
import path from "node:path";
import { summarizeStatus, isNonEmpty, commandLooksLike, summarizeProbeDetail } from "@agentcompany/adapter-utils/test-utils";
import { parseCodexJsonl } from "./parse.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function commandLooksLike(command: string, expected: string): boolean {
const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
}
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
const clean = raw.replace(/\s+/g, " ").trim();
const max = 240;
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
}
const CODEX_AUTH_REQUIRED_RE =
/(?:not\s+logged\s+in|login\s+required|authentication\s+required|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|api[_\s-]?key.*required|please\s+run\s+`?codex\s+login`?)/i;
@@ -1,71 +1,10 @@
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
import { parseCommaArgs, parseEnvVars, parseEnvBindings, parseJsonObject } from "@agentcompany/adapter-utils/config-utils";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL,
} from "../index.js";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
@@ -3,19 +3,7 @@ import {
redactHomePathUserSegmentsInValue,
type TranscriptEntry,
} from "@agentcompany/adapter-utils";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
import { asRecord, safeJsonParse, errorText } from "@agentcompany/adapter-utils/parse-utils";
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
@@ -25,23 +13,6 @@ 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 msg =
(typeof rec.message === "string" && rec.message) ||
(typeof rec.error === "string" && rec.error) ||
(typeof rec.code === "string" && rec.code) ||
"";
if (msg) return msg;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
function stringifyUnknown(value: unknown): string {
if (typeof value === "string") return redactHomePathUserSegments(value);
if (value === null || value === undefined) return "";
@@ -1,44 +1,5 @@
import pc from "picocolors";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
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 stringifyUnknown(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = asRecord(value);
if (!rec) return "";
const msg =
(typeof rec.message === "string" && rec.message) ||
(typeof rec.error === "string" && rec.error) ||
(typeof rec.code === "string" && rec.code) ||
"";
if (msg) return msg;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
import { asRecord, errorText, asString, asNumber, stringifyUnknown } from "@agentcompany/adapter-utils/parse-utils";
function printTextMessage(prefix: string, colorize: (text: string) => string, messageRaw: unknown): void {
if (typeof messageRaw === "string") {
@@ -28,7 +28,7 @@ import {
isGeminiUnknownSessionError,
parseGeminiJsonl,
} from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const SKILLS_CANDIDATES = [
@@ -276,7 +276,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
}
const commandNotes = (() => {
const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."];
const notes: string[] = [useStdin
? "Prompt passed via stdin (exceeds command-line length limit)."
: "Prompt is passed to Gemini as the final positional argument."];
notes.push("Added --approval-mode yolo for unattended execution.");
if (!instructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
@@ -305,6 +307,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const apiAccessNote = renderApiAccessNote(env);
const prompt = `${instructionsPrefix}${envNote}${apiAccessNote}${renderedPrompt}`;
// When the prompt is short enough for a command-line argument, pass it
// directly as a positional arg (Gemini CLI's native interface). When it
// exceeds the OS limit (~32k chars on Windows), write it to a temp file
// and pass the content via stdin to avoid ENAMETOOLONG.
const SAFE_ARG_LIMIT = 30_000;
const useStdin = prompt.length > SAFE_ARG_LIMIT;
let promptFilePath: string | null = null;
if (useStdin) {
promptFilePath = path.join(os.tmpdir(), `agentcompany-gemini-prompt-${runId}.txt`);
await fs.writeFile(promptFilePath, prompt, "utf8");
}
const buildArgs = (resumeSessionId: string | null) => {
const args = ["--output-format", "stream-json"];
if (resumeSessionId) args.push("--resume", resumeSessionId);
@@ -316,8 +330,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
args.push("--sandbox=none");
}
if (extraArgs.length > 0) {
// Filter out flags the adapter already manages to prevent duplicates
// (e.g. user shell aliases that inject --approval-mode or --sandbox)
const managedFlags = [
"--approval-mode",
"--output-format",
@@ -327,7 +339,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const cleaned = filterConflictingArgs(extraArgs, managedFlags);
if (cleaned.length > 0) args.push(...cleaned);
}
args.push(prompt);
if (!useStdin) {
args.push(prompt);
}
return args;
};
@@ -354,6 +368,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
timeoutSec,
graceSec,
onLog,
...(useStdin ? { stdin: prompt } : {}),
});
return {
proc,
@@ -442,20 +457,27 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
};
};
const initial = await runAttempt(sessionId);
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
) {
await onLog(
"stderr",
`[agent-company] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toResult(retry, true, true);
}
try {
const initial = await runAttempt(sessionId);
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
) {
await onLog(
"stderr",
`[agent-company] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toResult(retry, true, true);
}
return toResult(initial);
return toResult(initial);
} finally {
// Clean up temp prompt file if used
if (promptFilePath) {
await fs.unlink(promptFilePath).catch(() => {});
}
}
}
@@ -8,64 +8,6 @@ export {
isGeminiTurnLimitResult,
} from "./parse.js";
export { listGeminiSkills, syncGeminiSkills } from "./skills.js";
import type { AdapterSessionCodec } from "@agentcompany/adapter-utils";
import { createStandardSessionCodec } from "@agentcompany/adapter-utils/session-codec";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId =
readNonEmptyString(record.sessionId) ??
readNonEmptyString(record.session_id) ??
readNonEmptyString(record.sessionID);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId =
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return (
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID)
);
},
};
export const sessionCodec = createStandardSessionCodec({ extraSessionIdKeys: ["sessionID"] });
@@ -1,4 +1,3 @@
import path from "node:path";
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
@@ -14,32 +13,9 @@ import {
parseObject,
runChildProcess,
} from "@agentcompany/adapter-utils/server-utils";
import { summarizeStatus, isNonEmpty, commandLooksLike, summarizeProbeDetail } from "@agentcompany/adapter-utils/test-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import { detectGeminiAuthRequired, parseGeminiJsonl } from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function commandLooksLike(command: string, expected: string): boolean {
const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
}
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
const clean = raw.replace(/\s+/g, " ").trim();
const max = 240;
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
@@ -1,8 +0,0 @@
export function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
@@ -1,56 +1,7 @@
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
import { parseCommaArgs, parseEnvVars, parseEnvBindings } from "@agentcompany/adapter-utils/config-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
@@ -1,17 +1,5 @@
import type { TranscriptEntry } from "@agentcompany/adapter-utils";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
import { asRecord, safeJsonParse, errorText } from "@agentcompany/adapter-utils/parse-utils";
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
@@ -31,23 +19,6 @@ function stringifyUnknown(value: unknown): string {
}
}
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = asRecord(value);
if (!rec) return "";
const msg =
(typeof rec.message === "string" && rec.message) ||
(typeof rec.error === "string" && rec.error) ||
(typeof rec.code === "string" && rec.code) ||
"";
if (msg) return msg;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
function collectTextEntries(messageRaw: unknown, ts: string, kind: "assistant" | "user"): TranscriptEntry[] {
if (typeof messageRaw === "string") {
const text = messageRaw.trim();
@@ -11,6 +11,7 @@ import {
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 {
@@ -25,15 +26,6 @@ async function ensureKimiSkillsInjected(
await createManagedSkillSymlinks(managedSkills, kimiSkillsHome(), onLog);
}
function normalizeEnv(input: unknown): Record<string, string> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, config, context, onLog } = ctx;
const command = asString(config.command, "kimi");
@@ -7,15 +7,7 @@ import {
ensureCommandResolvable,
ensurePathInEnv,
} from "@agentcompany/adapter-utils/server-utils";
function normalizeEnv(input: unknown): Record<string, string> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
import { normalizeEnv } from "@agentcompany/adapter-utils/test-utils";
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
@@ -1,56 +1,7 @@
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";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
export function buildKimiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const config: Record<string, unknown> = {};
if (v.cwd) config.cwd = v.cwd;
@@ -1,43 +1,5 @@
import pc from "picocolors";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
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 message =
asString(rec.message) ||
asString(data?.message) ||
asString(rec.name) ||
"";
if (message) return message;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
import { asRecord, safeJsonParse, asString, asNumber, errorText } from "@agentcompany/adapter-utils/parse-utils";
export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
@@ -17,6 +17,7 @@ import {
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";
@@ -26,15 +27,6 @@ const SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../../../../skills"),
];
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function parseModelProvider(model: string | null): string | null {
if (!model) return null;
const trimmed = model.trim();
@@ -1,64 +1,6 @@
import type { AdapterSessionCodec } from "@agentcompany/adapter-utils";
import { createStandardSessionCodec } from "@agentcompany/adapter-utils/session-codec";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId =
readNonEmptyString(record.sessionId) ??
readNonEmptyString(record.session_id) ??
readNonEmptyString(record.sessionID);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId =
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return (
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID)
);
},
};
export const sessionCodec = createStandardSessionCodec({ extraSessionIdKeys: ["sessionID"] });
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
@@ -5,6 +5,7 @@ import {
ensurePathInEnv,
runChildProcess,
} from "@agentcompany/adapter-utils/server-utils";
import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils";
const MODELS_CACHE_TTL_MS = 60_000;
@@ -39,15 +40,6 @@ function sortModels(models: AdapterModel[]): AdapterModel[] {
);
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function parseModelsOutput(stdout: string): AdapterModel[] {
const parsed: AdapterModel[] = [];
for (const raw of stdout.split(/\r?\n/)) {
@@ -12,41 +12,10 @@ import {
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";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
const clean = raw.replace(/\s+/g, " ").trim();
const max = 240;
return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean;
}
function normalizeEnv(input: unknown): Record<string, string> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
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;
@@ -1,54 +1,5 @@
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
import { parseCommaArgs, parseEnvVars, parseEnvBindings } from "@agentcompany/adapter-utils/config-utils";
export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
@@ -1,17 +1,5 @@
import type { TranscriptEntry } from "@agentcompany/adapter-utils";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
import { asRecord, safeJsonParse } from "@agentcompany/adapter-utils/parse-utils";
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
@@ -0,0 +1,61 @@
export function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
export function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
export function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
export function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
+47
View File
@@ -0,0 +1,47 @@
export function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
export function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
export function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = asRecord(value);
if (!rec) return "";
const msg =
(typeof rec.message === "string" && rec.message) ||
(typeof rec.error === "string" && rec.error) ||
(typeof rec.code === "string" && rec.code) ||
"";
if (msg) return msg;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
export function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
export function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
export function stringifyUnknown(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
+33 -1
View File
@@ -1,4 +1,4 @@
import { spawn, type ChildProcess } from "node:child_process";
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { constants as fsConstants, promises as fs, type Dirent } from "node:fs";
import path from "node:path";
import { StringDecoder } from "node:string_decoder";
@@ -354,6 +354,7 @@ export async function runChildProcess(
cwd: opts.cwd,
env: mergedEnv,
shell: false,
windowsHide: true,
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
}) as ChildProcessWithEvents;
@@ -468,6 +469,37 @@ export async function runChildProcess(
});
}
/**
* Kill a process and all its descendants.
* Windows: `taskkill /T /F` kills the full process tree.
* Unix: SIGKILL the process directly (CLI tools are leaf processes).
*/
function killProcessTree(pid: number): void {
if (process.platform === "win32") {
try {
execSync(`taskkill /T /F /PID ${pid}`, { stdio: "ignore", timeout: 5000 });
} catch { /* process already exited */ }
} else {
try {
process.kill(pid, "SIGKILL");
} catch { /* already dead */ }
}
}
/**
* Force-kill every child process tracked in `runningProcesses` and clear the map.
* Called by the worker during shutdown to ensure no CLI processes survive app quit.
*/
export function shutdownAllRunning(): void {
for (const [runId, entry] of runningProcesses) {
const pid = entry.child.pid;
if (pid != null) {
killProcessTree(pid);
}
runningProcesses.delete(runId);
}
}
/**
* Create symlinks for managed skills in the target directory.
* Per D-13: uses 'junction' type on Windows to avoid Developer Mode requirement.
@@ -0,0 +1,61 @@
import type { AdapterSessionCodec } from "./types.js";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
interface SessionCodecOptions {
/** Additional key aliases for sessionId beyond "sessionId" and "session_id" (e.g. "sessionID" for Gemini/OpenCode). */
extraSessionIdKeys?: string[];
}
/**
* Creates a standard session codec shared across adapters.
* All adapters use the same serialize/deserialize/getDisplayId logic,
* differing only in which keys they accept for sessionId.
*/
export function createStandardSessionCodec(options?: SessionCodecOptions): AdapterSessionCodec {
const extraKeys = options?.extraSessionIdKeys ?? [];
function readSessionId(record: Record<string, unknown>): string | null {
return (
readNonEmptyString(record.sessionId) ??
readNonEmptyString(record.session_id) ??
extraKeys.reduce<string | null>((found, key) => found ?? readNonEmptyString(record[key]), null)
);
}
function extractFields(record: Record<string, unknown>) {
const sessionId = readSessionId(record);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
}
return {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
return extractFields(raw as Record<string, unknown>);
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
return extractFields(params);
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return readSessionId(params);
},
};
}
+43
View File
@@ -0,0 +1,43 @@
import path from "node:path";
import type { AdapterEnvironmentCheck, AdapterEnvironmentTestResult } from "./types.js";
export function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
export function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
export function commandLooksLike(command: string, expected: string): boolean {
const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
}
export function summarizeProbeDetail(stdout: string, stderr: string, parsedError?: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
const clean = raw.replace(/\s+/g, " ").trim();
const max = 240;
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
}
export function normalizeEnv(input: unknown): Record<string, string> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
-24
View File
@@ -1,7 +1,3 @@
// ---------------------------------------------------------------------------
// Minimal adapter-facing interfaces (no drizzle dependency)
// ---------------------------------------------------------------------------
export interface AdapterAgent {
id: string;
companyId: string;
@@ -20,10 +16,6 @@ export interface AdapterRuntime {
taskKey: string | null;
}
// ---------------------------------------------------------------------------
// Execution types (moved from server/src/adapters/types.ts)
// ---------------------------------------------------------------------------
export interface UsageSummary {
inputTokens: number;
outputTokens: number;
@@ -137,10 +129,6 @@ export interface AdapterEnvironmentTestResult {
testedAt: string;
}
// ---------------------------------------------------------------------------
// Skill sync types -- adapter-level skill state and synchronization
// ---------------------------------------------------------------------------
export type AdapterSkillSyncMode = "unsupported" | "persistent" | "ephemeral";
export type AdapterSkillState =
@@ -246,10 +234,6 @@ export interface ServerAdapterModule {
) => Promise<HireApprovedHookResult>;
}
// ---------------------------------------------------------------------------
// UI types (moved from ui/src/adapters/types.ts)
// ---------------------------------------------------------------------------
export type TranscriptEntry =
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
@@ -264,19 +248,11 @@ export type TranscriptEntry =
export type StdoutLineParser = (line: string, ts: string) => TranscriptEntry[];
// ---------------------------------------------------------------------------
// CLI types (moved from cli/src/adapters/types.ts)
// ---------------------------------------------------------------------------
export interface CLIAdapterModule {
type: string;
formatStdoutEvent: (line: string, debug: boolean) => void;
}
// ---------------------------------------------------------------------------
// UI config form values (moved from ui/src/components/AgentConfigForm.tsx)
// ---------------------------------------------------------------------------
/** Skill reference passed from main process to worker for adapter injection. */
export interface ManagedSkillRef {
/** Skill slug used as directory name in the adapter's skill directory */
+306 -3
View File
@@ -432,12 +432,20 @@ export class AgentApiServer {
} catch { /* deliverable review interception is best-effort; fall through to normal flow */ }
}
// Validate status transition
const effectiveStatus = update.status ?? task.status;
const VALID_STATUSES = new Set(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]);
if (update.status && !VALID_STATUSES.has(update.status)) {
this.json(res, 400, { error: `Invalid task status: ${update.status}` });
return;
}
const input: Record<string, unknown> = {
id: task.id,
companyId: task.companyId,
title: update.title ?? task.title,
description: update.description ?? task.description,
status: update.status ?? task.status,
status: effectiveStatus,
priority: update.priority ?? task.priority,
projectId: task.projectId,
goalId: task.goalId,
@@ -447,6 +455,11 @@ export class AgentApiServer {
};
this.db.saveTask(input as Parameters<typeof this.db.saveTask>[0]);
// Apply status side-effect timestamps via advanceTaskStatus if status changed
if (update.status && update.status !== task.status) {
this.db.advanceTaskStatus(task.id, update.status);
}
if (update.comment) {
const agent = this.db.getAgent(token.agentId);
this.db.addComment({
@@ -861,6 +874,31 @@ export class AgentApiServer {
return;
}
// Prevent duplicate agents: reject if a non-terminated agent with the same name already exists
const existingAgents = this.db.listSnapshot().agents.filter(
(a) => a.companyId === companyId && a.status !== "terminated",
);
const duplicateByName = existingAgents.find(
(a) => a.name.toLowerCase() === input.name.toLowerCase(),
);
if (duplicateByName) {
this.json(res, 409, { error: `Agent "${input.name}" already exists in this company (id: ${duplicateByName.id}, role: ${duplicateByName.role}). Use the existing agent instead of creating a duplicate.` });
return;
}
// Also check pending hire approvals with the same name to prevent concurrent duplicate requests
const pendingHires = this.db.listSnapshot().approvals.filter(
(a) => a.companyId === companyId && a.type === "hire_agent" && a.state === "pending",
);
for (const pending of pendingHires) {
try {
const payload = pending.payloadJson ? JSON.parse(pending.payloadJson) as Record<string, unknown> : {};
if (typeof payload.name === "string" && payload.name.toLowerCase() === input.name.toLowerCase()) {
this.json(res, 409, { error: `A hire request for "${input.name}" is already pending approval.` });
return;
}
} catch { /* invalid payloadJson, skip */ }
}
const managerLabel = input.reportsTo
? (() => {
try {
@@ -975,6 +1013,16 @@ export class AgentApiServer {
const chain = this.db.getChainOfCommand(token.agentId);
if (chain.length > 0 && chain[0].id !== token.agentId && !wokenForComment.has(chain[0].id)) {
this.onWakeAgent(chain[0].id, task.companyId, "comment");
wokenForComment.add(chain[0].id);
}
// @mention detection: wake any mentioned agents not already woken
const mentionedIds = this.db.findMentionedAgentIds(task.companyId, input.body);
for (const mentionedId of mentionedIds) {
if (mentionedId !== token.agentId && !wokenForComment.has(mentionedId)) {
this.onWakeAgent(mentionedId, task.companyId, "comment_mentioned");
wokenForComment.add(mentionedId);
}
}
this.json(res, 201, comment);
@@ -1115,6 +1163,88 @@ export class AgentApiServer {
this.json(res, 403, { error: "Approvals must be decided from the desktop board, not from an agent run token" });
});
this.addRoute("GET", "/api/approvals/:approvalId/comments", (_req, res, params, token) => {
const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; }
const comments = this.db.listApprovalComments(params.approvalId!, approval.companyId);
this.json(res, 200, comments);
});
this.addRoute("POST", "/api/approvals/:approvalId/comments", (_req, res, params, token, body) => {
const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; }
const data = body as Record<string, unknown>;
const commentBody = typeof data.body === "string" ? data.body.trim() : "";
if (!commentBody) { this.json(res, 400, { error: "Comment body is required" }); return; }
const commentId = this.db.addApprovalComment({
companyId: approval.companyId,
approvalId: approval.id,
authorAgentId: token.agentId ?? null,
body: commentBody,
});
// Wake mentioned agents in comment
const mentionedIds = this.db.findMentionedAgentIds(approval.companyId, commentBody);
for (const mentionedId of mentionedIds) {
if (mentionedId !== token.agentId) {
this.onWakeAgent(mentionedId, approval.companyId, "approval_comment_mentioned");
}
}
this.onDomainChanged();
this.json(res, 201, { id: commentId });
});
this.addRoute("POST", "/api/approvals/:approvalId/resubmit", (_req, res, params, token) => {
const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; }
if (approval.state !== "revision_requested") {
this.json(res, 422, { error: "Only revision_requested approvals can be resubmitted" });
return;
}
if (token.agentId !== approval.requestedByAgentId) {
this.json(res, 403, { error: "Only the requesting agent can resubmit this approval" });
return;
}
this.db.resubmitApproval(approval.id, approval.companyId);
this.onDomainChanged();
this.json(res, 200, { resubmitted: true });
});
this.addRoute("GET", "/api/approvals/:approvalId/tasks", (_req, res, params, token) => {
const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; }
const taskIds = this.db.listTasksForApproval(params.approvalId!);
this.json(res, 200, taskIds);
});
this.addRoute("POST", "/api/approvals/:approvalId/tasks", (_req, res, params, token, body) => {
const approval = this.db.listSnapshot().approvals.find((a) => a.id === params.approvalId);
if (!approval) { this.json(res, 404, { error: "Approval not found" }); return; }
if (approval.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; }
const data = body as Record<string, unknown>;
const taskIds = Array.isArray(data.taskIds) ? data.taskIds.filter((id): id is string => typeof id === "string") : [];
if (taskIds.length === 0) { this.json(res, 400, { error: "taskIds array is required" }); return; }
for (const taskId of taskIds) {
if (!this.db.belongsToCompany("tasks", taskId, token.companyId)) {
this.json(res, 400, { error: `Task ${taskId} does not belong to this company` }); return;
}
}
this.db.linkManyTasksToApproval(params.approvalId!, taskIds, token.companyId, token.agentId ?? null);
this.onDomainChanged();
this.json(res, 200, { linked: taskIds.length });
});
this.addRoute("GET", "/api/companies/:companyId/documents/:documentId/revisions", (_req, res, params, token) => {
if (params.companyId !== token.companyId) { this.json(res, 403, { error: "Company mismatch" }); return; }
const documentId = params.documentId;
if (!documentId) { this.json(res, 400, { error: "Missing documentId" }); return; }
const revisions = this.db.listDocumentRevisions(documentId, token.companyId);
this.json(res, 200, revisions);
});
this.addRoute("GET", "/api/companies/:companyId/agents", (_req, res, params, token) => {
if (params.companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
@@ -1506,6 +1636,180 @@ export class AgentApiServer {
this.json(res, 200, docs);
});
this.addRoute("PATCH", "/api/companies/:companyId/documents/:documentId", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
return;
}
const documentId = params.documentId;
if (!documentId) {
this.json(res, 400, { error: "Missing documentId" });
return;
}
const snapshot = this.db.listSnapshot();
const doc = snapshot.documents.find(d => d.id === documentId && d.companyId === token.companyId);
if (!doc) {
this.json(res, 404, { error: "Document not found in this company" });
return;
}
const data = body as Record<string, unknown>;
const VALID_STATUSES = new Set(["draft", "in_review", "approved", "archived", "superseded"]);
// Conflict detection: if baseRevisionId provided, verify it matches current
if (typeof data.baseRevisionId === "string") {
const currentRevId = this.db.getDocumentLatestRevisionId(documentId);
if (currentRevId && data.baseRevisionId !== currentRevId) {
this.json(res, 409, { error: "Document was updated by someone else", currentRevisionId: currentRevId });
return;
}
}
if (data.status !== undefined) {
const newStatus = String(data.status);
if (!VALID_STATUSES.has(newStatus)) {
this.json(res, 400, { error: `Invalid status "${newStatus}". Must be one of: ${[...VALID_STATUSES].join(", ")}` });
return;
}
this.db.updateDocumentStatus(documentId, token.companyId, newStatus);
}
// Content update → create revision for audit trail
if (data.content !== undefined) {
const newContent = String(data.content);
this.db.createDocumentRevision({
documentId,
companyId: token.companyId,
body: newContent,
changeSummary: typeof data.changeSummary === "string" ? data.changeSummary : null,
createdByAgentId: token.agentId ?? null,
});
}
// Field updates (title, reviewer, tags, etc.)
const reviewerAgentId = data.reviewerAgentId !== undefined
? (data.reviewerAgentId ? String(data.reviewerAgentId) : null)
: doc.reviewerAgentId;
if (data.reviewerAgentId !== undefined && reviewerAgentId && !this.db.belongsToCompany("agents", reviewerAgentId, token.companyId)) {
this.json(res, 400, { error: "Reviewer agent does not belong to the current company" });
return;
}
this.db.saveDocument({
id: documentId,
companyId: token.companyId,
type: data.type ? String(data.type) : doc.type,
title: data.title ? String(data.title) : doc.title,
content: data.content !== undefined ? String(data.content) : doc.content,
authorAgentId: doc.authorAgentId,
reviewerAgentId,
projectId: doc.projectId,
goalId: doc.goalId,
status: data.status ? String(data.status) : doc.status,
tagsJson: data.tagsJson ? String(data.tagsJson) : doc.tagsJson,
});
this.onDomainChanged();
this.json(res, 200, { updated: true, latestRevisionId: this.db.getDocumentLatestRevisionId(documentId) });
});
// ── Work Products ──
this.addRoute("GET", "/api/issues/:issueId/work-products", (_req, res, params, token) => {
try {
const task = this.db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; }
const products = this.db.listWorkProductsForTask(task.id, task.companyId);
this.json(res, 200, products);
} catch { this.json(res, 404, { error: "Task not found" }); }
});
this.addRoute("POST", "/api/issues/:issueId/work-products", (_req, res, params, token, body) => {
try {
const task = this.db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; }
const data = body as Record<string, unknown>;
if (!data.type || !data.provider || !data.title) {
this.json(res, 400, { error: "type, provider, and title are required" }); return;
}
const id = this.db.createWorkProduct({
companyId: task.companyId,
taskId: task.id,
projectId: typeof data.projectId === "string" ? data.projectId : task.projectId,
type: String(data.type),
provider: String(data.provider),
externalId: typeof data.externalId === "string" ? data.externalId : null,
title: String(data.title),
url: typeof data.url === "string" ? data.url : null,
status: typeof data.status === "string" ? data.status : "active",
reviewState: typeof data.reviewState === "string" ? data.reviewState : "none",
isPrimary: data.isPrimary === true,
summary: typeof data.summary === "string" ? data.summary : null,
metadataJson: data.metadata ? JSON.stringify(data.metadata) : null,
createdByRunId: token.runId ?? null,
});
this.onDomainChanged();
this.json(res, 201, { id });
} catch { this.json(res, 404, { error: "Task not found" }); }
});
this.addRoute("PATCH", "/api/work-products/:workProductId", (_req, res, params, token, body) => {
const wpId = params.workProductId;
if (!wpId) { this.json(res, 400, { error: "Missing workProductId" }); return; }
const data = body as Record<string, unknown>;
this.db.updateWorkProduct(wpId, token.companyId, data);
this.onDomainChanged();
this.json(res, 200, { updated: true });
});
this.addRoute("DELETE", "/api/work-products/:workProductId", (_req, res, params, token) => {
const wpId = params.workProductId;
if (!wpId) { this.json(res, 400, { error: "Missing workProductId" }); return; }
this.db.deleteWorkProduct(wpId, token.companyId);
this.onDomainChanged();
this.json(res, 200, { deleted: true });
});
// ── Task-Document Links ──
this.addRoute("GET", "/api/issues/:issueId/documents", (_req, res, params, token) => {
try {
const task = this.db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; }
const links = this.db.listDocumentsForTask(task.id, task.companyId);
this.json(res, 200, links);
} catch { this.json(res, 404, { error: "Task not found" }); }
});
this.addRoute("PUT", "/api/issues/:issueId/documents/:key", (_req, res, params, token, body) => {
try {
const task = this.db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; }
const key = String(params.key ?? "").trim().toLowerCase();
if (!key || !/^[a-z0-9][a-z0-9_-]*$/.test(key) || key.length > 64) {
this.json(res, 400, { error: "Invalid document key. Must be lowercase alphanumeric with _ or -, 1-64 chars." }); return;
}
const data = body as Record<string, unknown>;
const documentId = typeof data.documentId === "string" ? data.documentId : null;
if (!documentId) { this.json(res, 400, { error: "documentId is required" }); return; }
if (!this.db.belongsToCompany("documents", documentId, token.companyId)) {
this.json(res, 400, { error: "Document does not belong to this company" }); return;
}
const linkId = this.db.linkDocumentToTask(task.id, documentId, task.companyId, key);
this.onDomainChanged();
this.json(res, 200, { id: linkId, key });
} catch { this.json(res, 404, { error: "Task not found" }); }
});
this.addRoute("DELETE", "/api/issues/:issueId/documents/:key", (_req, res, params, token) => {
try {
const task = this.db.getTask(params.issueId!);
if (task.companyId !== token.companyId) { this.json(res, 404, { error: "Task not found" }); return; }
const key = String(params.key ?? "").trim().toLowerCase();
this.db.unlinkDocumentFromTask(task.id, key, task.companyId);
this.onDomainChanged();
this.json(res, 200, { unlinked: true });
} catch { this.json(res, 404, { error: "Task not found" }); }
});
this.addRoute("POST", "/api/companies/:companyId/meetings", (_req, res, params, token, body) => {
if (params.companyId !== token.companyId) {
this.json(res, 403, { error: "Company mismatch" });
@@ -1597,7 +1901,7 @@ export class AgentApiServer {
// Log but skip invalid meeting action items rather than failing the whole request
continue;
}
const taskId = this.db.saveTask({
this.db.saveTask({
companyId: token.companyId,
title: item.title,
description: item.description ?? `Action item from meeting: ${data.title ?? existing.title}`,
@@ -1608,7 +1912,6 @@ export class AgentApiServer {
if (item.assigneeAgentId) {
this.onWakeAgent(item.assigneeAgentId, token.companyId, "assignment");
}
void taskId;
}
}
}
+184 -27
View File
@@ -73,6 +73,126 @@ interface AutomationDependencies {
isQuitting: () => boolean;
}
const DEPARTMENT_SKILL_KEYWORDS: Record<string, string[]> = {
engineering: ["code", "engineer", "develop", "program", "software", "api", "backend", "frontend", "full-stack", "fullstack", "devops", "infrastructure", "system", "architect", "debug", "test", "ci", "cd", "deploy", "docker", "kubernetes", "database", "sql", "git", "typescript", "javascript", "python", "rust", "go", "java", "react", "node", "css", "html", "web"],
product: ["product", "feature", "requirement", "roadmap", "user story", "ux", "ui", "design", "prototype", "wireframe", "specification", "prd"],
marketing: ["market", "content", "seo", "social media", "brand", "campaign", "analytics", "growth", "copywrite", "blog", "newsletter", "email"],
sales: ["sales", "crm", "lead", "pipeline", "deal", "revenue", "prospect", "outbound", "cold"],
hr: ["hr", "human resource", "hiring", "recruit", "onboard", "performance", "culture", "talent", "people"],
finance: ["finance", "budget", "accounting", "cost", "revenue", "profit", "spend", "invoice", "tax", "payroll"],
design: ["design", "ui", "ux", "visual", "figma", "sketch", "prototype", "illustration", "typography", "layout", "color", "icon"],
research: ["research", "analysis", "data", "study", "survey", "experiment", "insight", "benchmark", "report"],
operations: ["operations", "process", "workflow", "efficiency", "supply chain", "logistics", "coordination"],
customer_support: ["support", "customer", "ticket", "helpdesk", "troubleshoot", "faq", "documentation"],
legal: ["legal", "compliance", "contract", "regulation", "policy", "privacy", "ip", "intellectual property"],
executive: ["strategy", "leadership", "management", "executive", "ceo", "cto", "coo", "cfo", "cmo", "vp"],
};
function parseDocumentIdsFromPayload(payloadJson: string | null): string[] {
if (!payloadJson) return [];
try {
const parsed = JSON.parse(payloadJson) as Record<string, unknown>;
const ids = parsed.documentIds;
if (Array.isArray(ids)) return ids.filter((id): id is string => typeof id === "string");
} catch { /* invalid JSON */ }
return [];
}
function scoreSkillAgentMatch(
skill: { name: string; description: string | null; markdown: string },
agent: { role: string; department: string | null; title: string | null; capabilities: string },
): number {
const agentText = [
agent.role,
agent.department ?? "",
agent.title ?? "",
agent.capabilities,
].join(" ").toLowerCase();
const skillText = [
skill.name,
skill.description ?? "",
skill.markdown.slice(0, 500),
].join(" ").toLowerCase();
let score = 0;
const dept = agent.department ?? "";
const deptKeywords = DEPARTMENT_SKILL_KEYWORDS[dept] ?? [];
for (const keyword of deptKeywords) {
if (skillText.includes(keyword)) {
score += 2;
}
}
const agentWords = agentText
.replace(/[^a-z0-9\s-]/g, " ")
.split(/\s+/)
.filter((w) => w.length >= 3);
for (const word of agentWords) {
if (skillText.includes(word)) {
score += 1;
}
}
// Executives need awareness of all domains
const executiveRoles = ["ceo", "cto", "coo", "cfo", "cmo", "vp", "chief", "director", "head", "lead"];
const roleLower = agent.role.toLowerCase();
if (executiveRoles.some((r) => roleLower.includes(r))) {
score += 1;
}
return score;
}
export function autoAssignSkillsToAgent(
db: AppDatabase,
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void },
companyId: string,
agentId: string,
): number {
const agent = db.getAgent(agentId);
if (!agent || agent.companyId !== companyId) return 0;
const allSkills = db.listCompanySkills(companyId);
if (allSkills.length === 0) return 0;
const existingAssignments = new Set(db.listAgentSkillAssignments(agentId));
const scored = allSkills
.filter((skill) => !existingAssignments.has(skill.id))
.map((skill) => ({
skill,
score: scoreSkillAgentMatch(skill, agent),
}))
.sort((a, b) => b.score - a.score);
const MIN_SCORE = 3;
let assigned = 0;
for (const { skill, score } of scored) {
if (score < MIN_SCORE) continue;
try {
db.assignSkillToAgent(agentId, skill.id);
assigned++;
logger.info(
`[skill-autoassign] Assigned "${skill.name}" to ${agent.name} (${agent.role}) — score ${score}`,
);
} catch (err) {
logger.warn(
`[skill-autoassign] Failed to assign "${skill.name}" to ${agent.name}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (assigned === 0) {
logger.info(
`[skill-autoassign] No matching skills for ${agent.name} (${agent.role}); agent will run without skills until manually assigned`,
);
}
return assigned;
}
interface RunFinishedEvent {
status: RunStatus;
summary: string | null;
@@ -202,10 +322,9 @@ export function createAutomationService({
}
function matchesConditions(
rule: { conditionsJson: string; sourceDepartment: string | null; targetDepartment: string | null },
rule: { conditionsJson: string; sourceDepartment: string | null },
ctx: AutomationEventContext,
) {
void rule.targetDepartment;
return matchesEventConfig(rule.conditionsJson, rule.sourceDepartment, ctx);
}
@@ -1067,11 +1186,30 @@ export function createAutomationService({
entityId: agent.id,
detail: `${agent.name} hire approved and activated.`,
});
const skillsAssigned = autoAssignSkillsToAgent(db, logger, companyId, agent.id);
if (skillsAssigned > 0) {
db.addActivity({
companyId,
actor: "system",
action: "skills.auto_assigned",
entityType: "agent",
entityId: agent.id,
detail: `Auto-assigned ${skillsAssigned} skill(s) to ${agent.name} based on role/department matching.`,
});
}
dispatchAutomation("hire_approved", {
companyId,
agentId: agent.id,
});
wakeAgentIfPossible(agent.id, companyId, "assignment");
const ceo = db.getCeoForCompany(companyId);
if (ceo && ceo.id !== agent.id) {
wakeAgentIfPossible(ceo.id, companyId, "assignment");
}
publishDomainChanged();
notify({
title: "Agent Hired",
@@ -1086,39 +1224,58 @@ export function createAutomationService({
}
}
if (approval.type === "deliverable_review" && approval.relatedTaskId) {
if (approval.type === "deliverable_review") {
try {
const task = db.getTask(approval.relatedTaskId);
// Re-enable heartbeat for the producing agent now that the user has decided
if (task.assigneeAgentId) {
// Update linked document statuses from payloadJson
const documentIds = parseDocumentIdsFromPayload(approval.payloadJson);
const docStatus = decision === "approved" ? "approved" : decision === "rejected" ? "draft" : "in_review";
for (const docId of documentIds) {
db.updateDocumentStatus(docId, companyId, docStatus);
}
if (approval.relatedTaskId) {
const task = db.getTask(approval.relatedTaskId);
if (task.assigneeAgentId) {
try {
const agent = db.getAgent(task.assigneeAgentId);
if (!agent.heartbeatEnabled) {
db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
}
} catch { /* agent lookup non-fatal */ }
}
if (decision === "approved") {
db.advanceTaskStatus(task.id, "done", "Board approved deliverable.");
handleTaskStatusChange(task.id, companyId, task.status, "done");
db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." });
} else if (decision === "rejected") {
db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`);
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
if (task.assigneeAgentId) {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
}
} else if (decision === "revision_requested") {
if (task.assigneeAgentId) {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
}
}
} else if (approval.requestedByAgentId) {
// Document-only review (no task) — resume the producing agent
try {
const agent = db.getAgent(task.assigneeAgentId);
const agent = db.getAgent(approval.requestedByAgentId);
if (!agent.heartbeatEnabled) {
db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120);
db.setHeartbeat(approval.requestedByAgentId, true, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
}
} catch { /* best effort */ }
}
if (decision === "approved") {
db.advanceTaskStatus(task.id, "done", "Board approved deliverable.");
handleTaskStatusChange(task.id, companyId, task.status, "done");
db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." });
publishDomainChanged();
} else if (decision === "rejected") {
db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`);
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
if (task.assigneeAgentId) {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
} catch { /* agent lookup non-fatal */ }
if (decision !== "approved") {
wakeAgentIfPossible(approval.requestedByAgentId, companyId, "revision_needed");
}
publishDomainChanged();
} else if (decision === "revision_requested") {
if (task.assigneeAgentId) {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
}
publishDomainChanged();
}
publishDomainChanged();
} catch {
// Deliverable review follow-up is best-effort.
// Deliverable review follow-up is non-fatal — approval state is already committed.
}
}
+38 -1
View File
@@ -67,7 +67,7 @@ export async function bootstrapApplication({
}: BootstrapApplicationDependencies) {
await app.whenReady();
log.initialize();
await prepareHostEnvironment({ iconPath, logger: log });
await prepareHostEnvironment({ logger: log });
const db = appDatabaseFactory(profileDir);
db.init();
@@ -280,6 +280,43 @@ export async function bootstrapApplication({
serviceGraph.getOrchestrationService()?.resumeQueuedRuns();
// Resume tasks that were interrupted by a clean shutdown.
// The worker-service destroy() marks running/queued runs as "interrupted" with
// summary "Application shut down." but does NOT reset the tasks, so agents
// never re-wake to continue them. Fix: reset these tasks back to "todo" and
// wake the assigned agents via heartbeat.
try {
const snapshot = db.listSnapshot();
const shutdownRuns = snapshot.runs.filter(
(r) => r.status === "interrupted" &&
(r.summary === "Application shut down." || r.summary === "Worker process crashed."),
);
if (shutdownRuns.length > 0) {
const resumedAgents = new Set<string>();
for (const run of shutdownRuns) {
try {
const task = db.getTask(run.taskId);
if (task.status === "in_progress" || task.status === "blocked") {
db.advanceTaskStatus(run.taskId, "todo", "Auto-resumed after app restart.");
db.releaseTaskLock(run.taskId, run.id);
}
if (!resumedAgents.has(run.agentId)) {
resumedAgents.add(run.agentId);
wakeAgentIfPossible(run.agentId, run.companyId, "app_restart_resume");
}
} catch {
// Task or agent may have been deleted — skip
}
}
if (resumedAgents.size > 0) {
log.info(`[bootstrap] Resumed ${shutdownRuns.length} interrupted task(s) across ${resumedAgents.size} agent(s) after clean shutdown recovery.`);
publishDomainChanged();
}
}
} catch (err) {
log.warn(`[bootstrap] Shutdown recovery failed: ${err instanceof Error ? err.message : String(err)}`);
}
return {
db,
apiServer,
+6 -7
View File
@@ -29,13 +29,12 @@ export function createBrowserActionService({
throw new Error(`Browser action ${actionId} is ${action.status} and cannot be dispatched.`);
}
if (action.approvalId) {
const approval = (() => {
try {
return db.getApprovalRecord(action.approvalId);
} catch {
return null;
}
})();
let approval: ReturnType<typeof db.getApprovalRecord> | null = null;
try {
approval = db.getApprovalRecord(action.approvalId);
} catch {
// Record not found
}
if (!approval || approval.companyId !== companyId) {
throw new Error(`Browser action approval ${action.approvalId} does not belong to company ${companyId}.`);
}
+103 -55
View File
@@ -10,49 +10,11 @@ function escapeAppleScript(value: string) {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function connectorAuthSpec(connectorId: ConnectorId, command: string) {
switch (connectorId) {
case "codex_local":
return {
title: "Codex Login",
shellCommand: `${quoteShell(command)} login`,
intro: "AgentCompany is opening the Codex login flow.",
};
case "claude_local":
return {
title: "Claude Code Login",
shellCommand: `${quoteShell(command)} auth login`,
intro: "AgentCompany is opening the Claude Code login flow.",
};
case "gemini_local":
return {
title: "Gemini CLI Login",
shellCommand: `${quoteShell(command)}`,
intro: "AgentCompany is opening Gemini CLI. If authentication is required, use /auth in the Gemini session.",
};
}
}
function buildTerminalCommand(connectorId: ConnectorId, command: string, cwd: string) {
const spec = connectorAuthSpec(connectorId, command);
const shell = process.env.SHELL || "/bin/zsh";
const script = [
"clear",
`echo ${quoteShell(spec.intro)}`,
"echo",
`cd ${quoteShell(cwd)}`,
spec.shellCommand,
"echo",
`echo ${quoteShell("You can close this terminal after the login flow completes.")}`,
`exec ${quoteShell(shell)} -l`,
].join("; ");
return { title: spec.title, script };
}
export async function openConnectorAuthTerminal(connectorId: ConnectorId, command: string, cwd?: string) {
const targetCwd = cwd || process.env.HOME || homedir() || process.cwd();
const { script } = buildTerminalCommand(connectorId, command, targetCwd);
/* ------------------------------------------------------------------ */
/* Shared terminal spawning — used by both auth and generic commands */
/* ------------------------------------------------------------------ */
function spawnTerminalWithScript(script: string, title: string, cwd: string): boolean {
if (process.platform === "darwin") {
const appleScript = `tell application "Terminal" to do script "${escapeAppleScript(script)}"\ntell application "Terminal" to activate`;
const child = spawn("osascript", ["-e", appleScript], {
@@ -64,18 +26,7 @@ export async function openConnectorAuthTerminal(connectorId: ConnectorId, comman
}
if (process.platform === "win32") {
const spec = connectorAuthSpec(connectorId, command);
const winScript = [
`@echo off`,
`echo ${spec.intro}`,
`echo.`,
`cd /d "${targetCwd}"`,
spec.shellCommand,
`echo.`,
`echo You can close this terminal after the login flow completes.`,
`pause`,
].join(" & ");
const child = spawn("cmd.exe", ["/c", "start", "", "cmd.exe", "/k", winScript], {
const child = spawn("cmd.exe", ["/c", "start", "", "cmd.exe", "/k", script], {
detached: true,
stdio: "ignore",
shell: false,
@@ -84,6 +35,7 @@ export async function openConnectorAuthTerminal(connectorId: ConnectorId, comman
return true;
}
// Linux — try multiple terminal emulators
const linuxCandidates: Array<[string, string[]]> = [
["x-terminal-emulator", ["-e", "sh", "-lc", script]],
["gnome-terminal", ["--", "sh", "-lc", script]],
@@ -110,5 +62,101 @@ export async function openConnectorAuthTerminal(connectorId: ConnectorId, comman
}
}
throw new Error("No supported terminal application was found for connector login.");
throw new Error("No supported terminal application was found.");
}
/* ------------------------------------------------------------------ */
/* Connector auth terminal (existing API — unchanged behavior) */
/* ------------------------------------------------------------------ */
function connectorAuthSpec(connectorId: ConnectorId, command: string) {
switch (connectorId) {
case "codex_local":
return {
title: "Codex Login",
shellCommand: `${quoteShell(command)} login`,
intro: "AgentCompany is opening the Codex login flow.",
};
case "claude_local":
return {
title: "Claude Code Login",
shellCommand: `${quoteShell(command)} auth login`,
intro: "AgentCompany is opening the Claude Code login flow.",
};
case "gemini_local":
return {
title: "Gemini CLI Login",
shellCommand: `${quoteShell(command)}`,
intro: "AgentCompany is opening Gemini CLI. If authentication is required, use /auth in the Gemini session.",
};
}
}
export async function openConnectorAuthTerminal(connectorId: ConnectorId, command: string, cwd?: string) {
const targetCwd = cwd || process.env.HOME || homedir() || process.cwd();
const spec = connectorAuthSpec(connectorId, command);
if (process.platform === "win32") {
// Windows: build a CMD-compatible script directly
const winScript = [
`@echo off`,
`echo ${spec.intro}`,
`echo.`,
`cd /d "${targetCwd}"`,
spec.shellCommand.replace(/'/g, ""), // Remove shell quotes for CMD
`echo.`,
`echo You can close this terminal after the login flow completes.`,
`pause`,
].join(" & ");
return spawnTerminalWithScript(winScript, spec.title, targetCwd);
}
// Unix: build a shell script
const shell = process.env.SHELL || "/bin/zsh";
const script = [
"clear",
`echo ${quoteShell(spec.intro)}`,
"echo",
`cd ${quoteShell(targetCwd)}`,
spec.shellCommand,
"echo",
`echo ${quoteShell("You can close this terminal after the login flow completes.")}`,
`exec ${quoteShell(shell)} -l`,
].join("; ");
return spawnTerminalWithScript(script, spec.title, targetCwd);
}
/* ------------------------------------------------------------------ */
/* Generic terminal command (for install flows, etc.) */
/* ------------------------------------------------------------------ */
export async function openGenericTerminal(input: { title: string; command: string }) {
const cwd = process.env.HOME || homedir() || process.cwd();
if (process.platform === "win32") {
const winScript = [
`@echo off`,
`echo AgentCompany - ${input.title}`,
`echo.`,
`cd /d "${cwd}"`,
input.command,
`echo.`,
`echo Done. You can close this terminal.`,
`pause`,
].join(" & ");
return spawnTerminalWithScript(winScript, input.title, cwd);
}
const shell = process.env.SHELL || "/bin/zsh";
const script = [
"clear",
`echo ${quoteShell(`AgentCompany — ${input.title}`)}`,
"echo",
`cd ${quoteShell(cwd)}`,
input.command,
"echo",
`echo ${quoteShell("Done. You can close this terminal.")}`,
`exec ${quoteShell(shell)} -l`,
].join("; ");
return spawnTerminalWithScript(script, input.title, cwd);
}
+35 -11
View File
@@ -103,27 +103,51 @@ export function createConnectorHealthService({
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Connector health check timed out after 45 seconds")), 45_000)),
]);
const authRequired = result.checks.some((check) =>
/auth_required|login_required/i.test(check.code) ||
/login is required|authentication is not ready|auth(?:entication)? required/i.test(check.message) ||
/run .*login|use \/auth|configure .*api key/i.test(check.hint ?? ""),
// Check if the CLI command itself is missing before inspecting auth status.
// Without this, info-level hints like "run `gemini auth login`" would wrongly
// trigger auth_required even when the CLI binary doesn't exist.
const commandMissing = result.checks.some((check) =>
/command_unresolvable|command_not_found/i.test(check.code) && check.level === "error",
);
// Only consider warn/error-level checks for auth detection.
// Info-level checks (like gemini_api_key_missing) carry advisory hints
// that match auth patterns but do NOT indicate actual auth failure.
const authRequired = !commandMissing && result.checks.some((check) =>
check.level !== "info" && (
/auth_required|login_required/i.test(check.code) ||
/login is required|authentication is not ready|auth(?:entication)? required/i.test(check.message) ||
/run .*login|use \/auth|configure .*api key/i.test(check.hint ?? "")
),
);
let authState: "ok" | "required" | "error" = "ok";
if (authRequired) {
if (commandMissing) {
authState = "error";
} else if (authRequired) {
authState = "required";
} else if (result.status === "fail") {
authState = "error";
}
const version = await parseVersion(connector.command);
const version = commandMissing ? null : await parseVersion(connector.command);
// claude_compatible requires explicit env bindings (API key + base URL) to be
// considered ready. Without them, it just uses the official claude CLI auth
// which is not what the user intends for a compatible provider.
const needsCompatConfig = id.startsWith("claude_compat")
&& !connector.envBindingText.includes("ANTHROPIC_BASE_URL");
db.updateConnectorHealth({
id,
status:
authRequired
? "auth_required"
: result.status === "pass" || result.status === "warn"
? "ready"
: "error",
commandMissing
? "not_installed"
: needsCompatConfig
? "auth_required"
: authRequired
? "auth_required"
: result.status === "pass" || result.status === "warn"
? "ready"
: "error",
authState,
version,
lastError:
+27 -56
View File
@@ -1,20 +1,7 @@
/**
* Deterministic connector-matching heuristic.
*
* Maps task types to the best available connector based on a static
* preference table. Provides fallback logic when preferred connectors
* are offline or unavailable.
*/
import type { ConnectorId, ConnectorStatus, TaskType } from "@shared/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ConnectorMatchInput {
interface ConnectorMatchInput {
taskType: TaskType;
agentRole: string;
agentDepartment: string | null;
availableConnectors: Array<{
id: ConnectorId;
status: ConnectorStatus;
@@ -22,30 +9,20 @@ export interface ConnectorMatchInput {
}>;
}
export interface ConnectorMatchResult {
interface ConnectorMatchResult {
recommendedConnectorId: ConnectorId;
fallbackConnectorId: ConnectorId | null;
reason: string;
}
// ---------------------------------------------------------------------------
// Static preference tables
// ---------------------------------------------------------------------------
/**
* For each task type, connectors ordered from most preferred to least.
*/
export const TASK_TYPE_PREFERENCE: Record<TaskType, ConnectorId[]> = {
code: ["claude_local", "codex_local", "gemini_local"],
research: ["gemini_local", "claude_local", "codex_local"],
content: ["claude_local", "gemini_local", "codex_local"],
management: ["claude_local", "gemini_local", "codex_local"],
general: ["claude_local", "codex_local", "gemini_local"],
code: ["claude_local", "codex_local", "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", "gemini_local"],
research: ["gemini_local", "claude_local", "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", "codex_local"],
content: ["claude_local", "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", "gemini_local", "codex_local"],
management: ["claude_local", "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", "gemini_local", "codex_local"],
general: ["claude_local", "codex_local", "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", "gemini_local"],
};
/**
* Maps department strings to the most likely task type for that department.
*/
export const ROLE_DEPARTMENT_HINTS: Record<string, TaskType> = {
engineering: "code",
research: "research",
@@ -61,28 +38,37 @@ export const ROLE_DEPARTMENT_HINTS: Record<string, TaskType> = {
legal: "management",
};
/**
* Human-readable capability descriptions per connector.
*/
export const CONNECTOR_STRENGTHS: Record<ConnectorId, string> = {
claude_local:
"Best for code generation, content writing, complex reasoning. Supports session resume, cost tracking.",
claude_compatible:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_2:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_3:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_4:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_5:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_6:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_7:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_8:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_9:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
claude_compat_10:
"Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.",
codex_local:
"Optimized for code editing and generation. Supports session resume, structured output.",
gemini_local:
"Strong at research, analysis, and large-context tasks. Good for data processing.",
};
// ---------------------------------------------------------------------------
// Functions
// ---------------------------------------------------------------------------
const READY_STATUSES: Set<ConnectorStatus> = new Set(["ready"]);
/**
* Infer the most likely task type from a department name.
* Returns 'general' for null, undefined, or unrecognised departments.
*/
export function inferTaskType(
department: string | null | undefined,
): TaskType {
@@ -91,25 +77,10 @@ export function inferTaskType(
return ROLE_DEPARTMENT_HINTS[lower] ?? "general";
}
/**
* Get a human-readable description of a connector's strengths.
*/
export function getConnectorStrengths(connectorId: ConnectorId): string {
return CONNECTOR_STRENGTHS[connectorId] ?? "General-purpose AI connector.";
}
/**
* Select the best connector for a given task type and set of available
* connectors. Falls back gracefully when preferred connectors are offline.
*
* Algorithm:
* 1. Filter connectors to those with "ready" status.
* 2. Walk the preference list for the given task type.
* 3. Return the first ready match as recommended.
* 4. Set fallback to the next *different* ready connector in the list.
* 5. If no connectors are ready, use the first available connector.
* 6. If no connectors at all, default to 'claude_local'.
*/
export function matchConnector(input: ConnectorMatchInput): ConnectorMatchResult {
const { taskType, availableConnectors } = input;
const prefList = TASK_TYPE_PREFERENCE[taskType] ?? TASK_TYPE_PREFERENCE.general;
+250
View File
@@ -87,6 +87,256 @@ 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,
},
{
id: "gemini_local",
label: "Gemini CLI",
+428 -88
View File
@@ -580,9 +580,9 @@ export class AppDatabase {
...companyArgs,
),
tasks: this.readAll<TaskRecord>(
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`,
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`,
...companyArgs,
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview) })) as TaskRecord[],
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[],
approvals: this.readAll<ApprovalRecord>(
`select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals${companyWhere} order by created_at desc`,
...companyArgs,
@@ -635,13 +635,13 @@ export class AppDatabase {
actionItemsJson: r.action_items_json, status: r.status, createdAt: r.created_at, updatedAt: r.updated_at,
})),
documents: this.readAll<SqliteRow>(
`select id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, created_at, updated_at from documents${companyWhere} order by updated_at desc`,
`select id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, latest_revision_id, created_at, updated_at from documents${companyWhere} order by updated_at desc`,
...companyArgs,
).map((r) => ({
id: r.id, companyId: r.company_id, type: r.type, title: r.title, content: r.content,
authorAgentId: r.author_agent_id, reviewerAgentId: r.reviewer_agent_id, projectId: r.project_id,
goalId: r.goal_id, parentDocId: r.parent_doc_id, version: r.version, status: r.status,
tagsJson: r.tags_json, createdAt: r.created_at, updatedAt: r.updated_at,
tagsJson: r.tags_json, latestRevisionId: r.latest_revision_id ?? null, createdAt: r.created_at, updatedAt: r.updated_at,
})),
knowledgeBase: this.readAll<SqliteRow>(
`select id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, created_at, updated_at from knowledge_base${companyWhere} order by updated_at desc`,
@@ -1131,11 +1131,14 @@ export class AppDatabase {
getTask(id: string): TaskRecord {
const row = this.db
.prepare(
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
)
.get(id) as TaskRecord | undefined;
if (!row) throw new Error(`Task not found: ${id}`);
row.requiresUserReview = Boolean(row.requiresUserReview);
row.startedAt = row.startedAt ?? null;
row.completedAt = row.completedAt ?? null;
row.cancelledAt = row.cancelledAt ?? null;
return row;
}
@@ -2124,90 +2127,62 @@ export class AppDatabase {
).map((record) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[];
}
enqueueWakeupRequest(companyId: string, agentId: string, trigger: string, detail: string): void {
enqueueWakeupRequest(companyId: string, agentId: string, trigger: string, detail: string, context?: Record<string, unknown>): void {
const ts = nowIso();
this.transaction(() => {
// D-07: Per-agent scope -- check for existing queued or deferred wake for this agent
const existing = this.db
.prepare(
`SELECT id, trigger_detail, coalesced_count
`SELECT id, trigger_detail, coalesced_count, context_snapshot
FROM wakeup_requests
WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'deferred')
LIMIT 1`,
)
.get(agentId, companyId) as
| { id: string; trigger_detail: string | null; coalesced_count: number }
| { id: string; trigger_detail: string | null; coalesced_count: number; context_snapshot: string | null }
| undefined;
if (existing) {
// D-06: Coalesce -- append trigger to JSON array
const triggers: Array<{ trigger: string; at: string; detail: string }> = existing.trigger_detail
? JSON.parse(existing.trigger_detail)
: [];
triggers.push({ trigger, at: ts, detail });
// Merge context snapshots (Paperclip pattern: incoming overwrites existing, preserve commentId)
let mergedContext: string | null = existing.context_snapshot;
if (context) {
const prev: Record<string, unknown> = existing.context_snapshot ? JSON.parse(existing.context_snapshot) : {};
const merged = { ...prev, ...context };
mergedContext = JSON.stringify(merged);
}
this.db
.prepare(
`UPDATE wakeup_requests
SET trigger_detail = ?,
coalesced_count = coalesced_count + 1,
context_snapshot = ?,
updated_at = ?
WHERE id = ?`,
)
.run(JSON.stringify(triggers), ts, existing.id);
.run(JSON.stringify(triggers), mergedContext, ts, existing.id);
} else {
// New wakeup request
const triggerDetail = JSON.stringify([{ trigger, at: ts, detail }]);
const contextJson = context ? JSON.stringify(context) : null;
this.db
.prepare(
`INSERT INTO wakeup_requests
(id, company_id, agent_id, source, trigger_detail, status, coalesced_count, requested_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
(id, company_id, agent_id, source, trigger_detail, status, coalesced_count, context_snapshot, requested_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?, ?)`,
)
.run(randomUUID(), companyId, agentId, trigger, triggerDetail, ts, ts, ts);
.run(randomUUID(), companyId, agentId, trigger, triggerDetail, contextJson, ts, ts, ts);
}
});
}
claimWakeupRequest(agentId: string, companyId: string, runId: string): { id: string; triggerDetail: string | null } | null {
let result: { id: string; triggerDetail: string | null } | null = null;
this.transaction(() => {
const row = this.db
.prepare(
`SELECT id, trigger_detail AS triggerDetail
FROM wakeup_requests
WHERE agent_id = ? AND company_id = ? AND status = 'queued'
ORDER BY requested_at ASC
LIMIT 1`,
)
.get(agentId, companyId) as { id: string; triggerDetail: string | null } | undefined;
if (!row) return;
const ts = nowIso();
this.db
.prepare(
`UPDATE wakeup_requests SET status = 'claimed', claimed_at = ?, run_id = ?, updated_at = ? WHERE id = ?`,
)
.run(ts, runId, ts, row.id);
result = row;
});
return result;
}
completeWakeupRequest(wakeupId: string): void {
const ts = nowIso();
this.db
.prepare(
`UPDATE wakeup_requests SET status = 'completed', finished_at = ?, updated_at = ? WHERE id = ? AND status = 'claimed'`,
)
.run(ts, ts, wakeupId);
}
deferWakeupRequest(agentId: string, companyId: string): void {
const ts = nowIso();
this.db
.prepare(
`UPDATE wakeup_requests SET status = 'deferred', updated_at = ? WHERE agent_id = ? AND company_id = ? AND status = 'queued'`,
)
.run(ts, agentId, companyId);
private mergeContextSnapshots(existingJson: string | null, incomingJson: string | null): string | null {
if (!incomingJson) return existingJson;
if (!existingJson) return incomingJson;
const existing: Record<string, unknown> = JSON.parse(existingJson);
const incoming: Record<string, unknown> = JSON.parse(incomingJson);
return JSON.stringify({ ...existing, ...incoming });
}
promoteWakeupRequests(agentId: string, companyId: string): number {
@@ -2215,36 +2190,35 @@ export class AppDatabase {
this.transaction(() => {
const queued = this.db
.prepare(
`SELECT id, trigger_detail FROM wakeup_requests
`SELECT id, trigger_detail, context_snapshot FROM wakeup_requests
WHERE agent_id = ? AND company_id = ? AND status = 'queued' LIMIT 1`,
)
.get(agentId, companyId) as { id: string; trigger_detail: string | null } | undefined;
const deferred = this.readAll<{ id: string; trigger_detail: string | null; coalesced_count: number }>(
`SELECT id, trigger_detail, coalesced_count FROM wakeup_requests
.get(agentId, companyId) as { id: string; trigger_detail: string | null; context_snapshot: string | null } | undefined;
const deferred = this.readAll<{ id: string; trigger_detail: string | null; coalesced_count: number; context_snapshot: string | null }>(
`SELECT id, trigger_detail, coalesced_count, context_snapshot FROM wakeup_requests
WHERE agent_id = ? AND company_id = ? AND status = 'deferred'`,
agentId, companyId,
);
if (deferred.length === 0) return;
const ts = nowIso();
if (queued) {
// Merge deferred triggers into existing queued wake
const queuedTriggers: Array<{ trigger: string; at: string; detail: string }> = queued.trigger_detail ? JSON.parse(queued.trigger_detail) : [];
let addedCount = 0;
let mergedCtx = queued.context_snapshot;
for (const d of deferred) {
const dTriggers: Array<{ trigger: string; at: string; detail: string }> = d.trigger_detail ? JSON.parse(d.trigger_detail) : [];
queuedTriggers.push(...dTriggers);
addedCount += d.coalesced_count + 1;
mergedCtx = this.mergeContextSnapshots(mergedCtx, d.context_snapshot);
}
this.db.prepare(
`UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, updated_at = ? WHERE id = ?`,
).run(JSON.stringify(queuedTriggers), addedCount, ts, queued.id);
// Delete the merged deferred rows
`UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, context_snapshot = ?, updated_at = ? WHERE id = ?`,
).run(JSON.stringify(queuedTriggers), addedCount, mergedCtx, ts, queued.id);
for (const d of deferred) {
this.db.prepare("DELETE FROM wakeup_requests WHERE id = ?").run(d.id);
}
promoted = deferred.length;
} else {
// No queued wake -- promote first deferred to queued, merge rest
const [first, ...rest] = deferred;
this.db.prepare(
`UPDATE wakeup_requests SET status = 'queued', updated_at = ? WHERE id = ?`,
@@ -2252,14 +2226,16 @@ export class AppDatabase {
if (rest.length > 0) {
const firstTriggers: Array<{ trigger: string; at: string; detail: string }> = first!.trigger_detail ? JSON.parse(first!.trigger_detail) : [];
let addedCount = 0;
let mergedCtx = first!.context_snapshot;
for (const d of rest) {
const dTriggers: Array<{ trigger: string; at: string; detail: string }> = d.trigger_detail ? JSON.parse(d.trigger_detail) : [];
firstTriggers.push(...dTriggers);
addedCount += d.coalesced_count + 1;
mergedCtx = this.mergeContextSnapshots(mergedCtx, d.context_snapshot);
}
this.db.prepare(
`UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, updated_at = ? WHERE id = ?`,
).run(JSON.stringify(firstTriggers), addedCount, ts, first!.id);
`UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, context_snapshot = ?, updated_at = ? WHERE id = ?`,
).run(JSON.stringify(firstTriggers), addedCount, mergedCtx, ts, first!.id);
for (const d of rest) {
this.db.prepare("DELETE FROM wakeup_requests WHERE id = ?").run(d.id);
}
@@ -2597,14 +2573,6 @@ export class AppDatabase {
return row ? this.mapSkillRow(row) : null;
}
/** Get a single skill by canonical key within a company. */
getCompanySkillByKey(companyId: string, key: string): SkillRecord | null {
const row = this.db
.prepare("SELECT * FROM company_skills WHERE company_id = ? AND key = ?")
.get(companyId, key) as SqliteRow | undefined;
return row ? this.mapSkillRow(row) : null;
}
/** Delete a skill record. Cascade deletes agent_skill_assignments via FK. */
deleteCompanySkill(companyId: string, skillId: string): boolean {
const result = this.db
@@ -2982,10 +2950,246 @@ export class AppDatabase {
return id;
}
updateDocumentStatus(id: string, companyId: string, status: string): void {
this.db.prepare(
"UPDATE documents SET status = ?, updated_at = ? WHERE id = ? AND company_id = ?",
).run(status, nowIso(), id, companyId);
}
deleteDocument(id: string, companyId: string) {
this.db.prepare("delete from documents where id = ? and company_id = ?").run(id, companyId);
}
createDocumentRevision(input: {
documentId: string; companyId: string; body: string;
changeSummary?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null;
}): { id: string; revisionNumber: number } {
const id = randomUUID();
const ts = nowIso();
const current = this.db.prepare(
"SELECT version FROM documents WHERE id = ? AND company_id = ?",
).get(input.documentId, input.companyId) as { version: number } | undefined;
if (!current) throw new Error(`Document ${input.documentId} not found`);
const revisionNumber = current.version + 1;
this.db.prepare(
"INSERT INTO document_revisions (id, company_id, document_id, revision_number, body, change_summary, created_by_agent_id, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
).run(id, input.companyId, input.documentId, revisionNumber, input.body, input.changeSummary ?? null, input.createdByAgentId ?? null, input.createdByUserId ?? null, ts);
this.db.prepare(
"UPDATE documents SET version = ?, latest_revision_id = ?, updated_at = ? WHERE id = ?",
).run(revisionNumber, id, ts, input.documentId);
return { id, revisionNumber };
}
getDocumentLatestRevisionId(documentId: string): string | null {
const row = this.db.prepare(
"SELECT latest_revision_id FROM documents WHERE id = ?",
).get(documentId) as { latest_revision_id: string | null } | undefined;
return row?.latest_revision_id ?? null;
}
listDocumentRevisions(documentId: string, companyId: string) {
return this.readAll<{
id: string; company_id: string; document_id: string; revision_number: number;
body: string; change_summary: string | null; created_by_agent_id: string | null;
created_by_user_id: string | null; created_at: string;
}>(
"SELECT id, company_id, document_id, revision_number, body, change_summary, created_by_agent_id, created_by_user_id, created_at FROM document_revisions WHERE document_id = ? AND company_id = ? ORDER BY revision_number ASC",
documentId, companyId,
).map((r) => ({
id: r.id, companyId: r.company_id, documentId: r.document_id,
revisionNumber: r.revision_number, body: r.body,
changeSummary: r.change_summary, createdByAgentId: r.created_by_agent_id,
createdByUserId: r.created_by_user_id, createdAt: r.created_at,
}));
}
addApprovalComment(input: {
companyId: string; approvalId: string; authorAgentId?: string | null;
authorUserId?: string | null; body: string;
}): string {
const id = randomUUID();
const ts = nowIso();
this.db.prepare(
"INSERT INTO approval_comments (id, company_id, approval_id, author_agent_id, author_user_id, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
).run(id, input.companyId, input.approvalId, input.authorAgentId ?? null, input.authorUserId ?? null, input.body, ts, ts);
return id;
}
listApprovalComments(approvalId: string, companyId: string) {
return this.readAll<{
id: string; company_id: string; approval_id: string; author_agent_id: string | null;
author_user_id: string | null; body: string; created_at: string; updated_at: string;
}>(
"SELECT id, company_id, approval_id, author_agent_id, author_user_id, body, created_at, updated_at FROM approval_comments WHERE approval_id = ? AND company_id = ? ORDER BY created_at ASC",
approvalId, companyId,
).map((r) => ({
id: r.id, companyId: r.company_id, approvalId: r.approval_id,
authorAgentId: r.author_agent_id, authorUserId: r.author_user_id,
body: r.body, createdAt: r.created_at, updatedAt: r.updated_at,
}));
}
findMentionedAgentIds(companyId: string, text: string): string[] {
const mentionRe = /\B@([^\s@,!?.]+)/g;
const tokens = new Set<string>();
let m: RegExpExecArray | null;
while ((m = mentionRe.exec(text)) !== null) {
tokens.add(m[1]!.toLowerCase());
}
if (tokens.size === 0) return [];
const agents = this.readAll<{ id: string; name: string }>(
"SELECT id, name FROM agents WHERE company_id = ?", companyId,
);
const matched: string[] = [];
for (const agent of agents) {
if (tokens.has(agent.name.toLowerCase())) {
matched.push(agent.id);
}
}
return matched;
}
resubmitApproval(approvalId: string, companyId: string): void {
const ts = nowIso();
this.db.prepare(
"UPDATE approvals SET state = 'pending', decision_note = '', updated_at = ? WHERE id = ? AND company_id = ? AND state = 'revision_requested'",
).run(ts, approvalId, companyId);
}
linkTaskToApproval(taskId: string, approvalId: string, companyId: string, linkedByAgentId?: string | null): void {
this.db.prepare(
"INSERT OR IGNORE INTO task_approval_links (task_id, approval_id, company_id, linked_by_agent_id, created_at) VALUES (?, ?, ?, ?, ?)",
).run(taskId, approvalId, companyId, linkedByAgentId ?? null, nowIso());
}
unlinkTaskFromApproval(taskId: string, approvalId: string): void {
this.db.prepare(
"DELETE FROM task_approval_links WHERE task_id = ? AND approval_id = ?",
).run(taskId, approvalId);
}
listApprovalsForTask(taskId: string): string[] {
return this.readAll<{ approval_id: string }>(
"SELECT approval_id FROM task_approval_links WHERE task_id = ?", taskId,
).map((r) => r.approval_id);
}
listTasksForApproval(approvalId: string): string[] {
return this.readAll<{ task_id: string }>(
"SELECT task_id FROM task_approval_links WHERE approval_id = ?", approvalId,
).map((r) => r.task_id);
}
linkManyTasksToApproval(approvalId: string, taskIds: string[], companyId: string, linkedByAgentId?: string | null): void {
const ts = nowIso();
for (const taskId of taskIds) {
this.db.prepare(
"INSERT OR IGNORE INTO task_approval_links (task_id, approval_id, company_id, linked_by_agent_id, created_at) VALUES (?, ?, ?, ?, ?)",
).run(taskId, approvalId, companyId, linkedByAgentId ?? null, ts);
}
}
// ── Work Products ──
createWorkProduct(input: {
companyId: string; taskId: string; projectId?: string | null;
type: string; provider: string; externalId?: string | null;
title: string; url?: string | null; status?: string; reviewState?: string;
isPrimary?: boolean; summary?: string | null; metadataJson?: string | null;
createdByRunId?: string | null;
}): string {
const id = randomUUID();
const ts = nowIso();
if (input.isPrimary) {
this.db.prepare(
"UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ?",
).run(ts, input.companyId, input.taskId, input.type);
}
this.db.prepare(
`INSERT INTO work_products (id, company_id, project_id, task_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id, input.companyId, input.projectId ?? null, input.taskId,
input.type, input.provider, input.externalId ?? null,
input.title, input.url ?? null, input.status ?? "active",
input.reviewState ?? "none", input.isPrimary ? 1 : 0,
input.summary ?? null, input.metadataJson ?? null,
input.createdByRunId ?? null, ts, ts,
);
return id;
}
updateWorkProduct(id: string, companyId: string, patch: Record<string, unknown>): void {
const ts = nowIso();
const sets: string[] = ["updated_at = ?"];
const values: unknown[] = [ts];
const ALLOWED = new Set(["type", "provider", "external_id", "title", "url", "status", "review_state", "is_primary", "summary", "metadata_json"]);
for (const [key, value] of Object.entries(patch)) {
const col = key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
if (ALLOWED.has(col)) {
sets.push(`${col} = ?`);
values.push(col === "is_primary" ? (value ? 1 : 0) : value ?? null);
}
}
values.push(id, companyId);
this.db.prepare(`UPDATE work_products SET ${sets.join(", ")} WHERE id = ? AND company_id = ?`).run(...values);
}
listWorkProductsForTask(taskId: string, companyId: string) {
return this.readAll<{
id: string; company_id: string; project_id: string | null; task_id: string;
type: string; provider: string; external_id: string | null; title: string;
url: string | null; status: string; review_state: string; is_primary: number;
summary: string | null; metadata_json: string | null; created_by_run_id: string | null;
created_at: string; updated_at: string;
}>(
"SELECT * FROM work_products WHERE task_id = ? AND company_id = ? ORDER BY is_primary DESC, updated_at DESC",
taskId, companyId,
).map((r) => ({
id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id,
type: r.type, provider: r.provider, externalId: r.external_id, title: r.title,
url: r.url, status: r.status, reviewState: r.review_state,
isPrimary: Boolean(r.is_primary), summary: r.summary,
metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id,
createdAt: r.created_at, updatedAt: r.updated_at,
}));
}
deleteWorkProduct(id: string, companyId: string): void {
this.db.prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?").run(id, companyId);
}
// ── Task-Document Links ──
linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string): string {
const id = randomUUID();
const ts = nowIso();
this.db.prepare(
"INSERT OR REPLACE INTO task_documents (id, company_id, task_id, document_id, key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(id, companyId, taskId, documentId, key.toLowerCase(), ts, ts);
return id;
}
unlinkDocumentFromTask(taskId: string, key: string, companyId: string): void {
this.db.prepare(
"DELETE FROM task_documents WHERE task_id = ? AND key = ? AND company_id = ?",
).run(taskId, key.toLowerCase(), companyId);
}
listDocumentsForTask(taskId: string, companyId: string) {
return this.readAll<{
id: string; company_id: string; task_id: string; document_id: string;
key: string; created_at: string; updated_at: string;
}>(
"SELECT * FROM task_documents WHERE task_id = ? AND company_id = ? ORDER BY key ASC",
taskId, companyId,
).map((r) => ({
id: r.id, companyId: r.company_id, taskId: r.task_id,
documentId: r.document_id, key: r.key,
createdAt: r.created_at, updatedAt: r.updated_at,
}));
}
saveKnowledgeEntry(input: {
id?: string | null; companyId: string; category: string; topic: string; content: string;
authorAgentId?: string | null; importance?: string; tagsJson?: string;
@@ -3319,13 +3523,45 @@ export class AppDatabase {
);
}
private static readonly VALID_TASK_STATUSES = new Set(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]);
advanceTaskStatus(taskId: string, newStatus: string, comment?: string): { previousStatus: string } {
if (!AppDatabase.VALID_TASK_STATUSES.has(newStatus)) {
throw new Error(`Invalid task status: ${newStatus}`);
}
const task = this.db.prepare("select status, company_id from tasks where id = ?").get(taskId) as { status: string; company_id: string } | undefined;
if (!task) throw new Error(`Task not found: ${taskId}`);
const previousStatus = task.status;
if (previousStatus === newStatus) return { previousStatus };
const ts = nowIso();
this.db.prepare("update tasks set status = ?, updated_at = ? where id = ?").run(newStatus, ts, taskId);
// Status side-effect timestamps (Paperclip pattern)
const timestampSets: string[] = ["status = ?", "updated_at = ?"];
const timestampValues: (string | null)[] = [newStatus, ts];
if (newStatus === "in_progress") {
// Set startedAt only once (first time entering in_progress)
timestampSets.push("started_at = COALESCE(started_at, ?)");
timestampValues.push(ts);
}
if (newStatus === "done") {
timestampSets.push("completed_at = ?");
timestampValues.push(ts);
} else {
timestampSets.push("completed_at = NULL");
}
if (newStatus === "cancelled") {
timestampSets.push("cancelled_at = ?");
timestampValues.push(ts);
} else {
timestampSets.push("cancelled_at = NULL");
}
if (newStatus !== "in_progress") {
timestampSets.push("checkout_run_id = NULL");
}
this.db.prepare(`UPDATE tasks SET ${timestampSets.join(", ")} WHERE id = ?`).run(...timestampValues, taskId);
if (comment) {
this.addComment({
companyId: task.company_id,
@@ -3335,11 +3571,9 @@ export class AppDatabase {
body: comment,
});
}
// Track sprint velocity: increment completed_points when task moves to "done"
if (newStatus === "done" && previousStatus !== "done") {
this.incrementActiveSprintVelocity(task.company_id, 1);
}
// Decrement if task moves back from done
if (previousStatus === "done" && newStatus !== "done") {
this.incrementActiveSprintVelocity(task.company_id, -1);
}
@@ -3405,17 +3639,6 @@ export class AppDatabase {
return manager ? { ...manager, companyId: agent.company_id } : null;
}
activateWorkflow(id: string, companyId: string) {
const row = this.db.prepare("select name, status from workflow_pipelines where id = ? and company_id = ?").get(id, companyId) as { name: string; status: string } | undefined;
if (!row) throw new Error(`Workflow not found: ${id}`);
if (row.status === "active") throw new Error("Workflow is already running.");
const ts = nowIso();
this.db.prepare(
"update workflow_pipelines set status = 'active', current_step_index = 0, run_count = run_count + 1, last_run_at = ?, updated_at = ? where id = ?",
).run(ts, ts, id);
this.addActivity({ companyId, actor: "system", action: "started", entityType: "workflow", entityId: id, detail: `Started workflow: ${row.name}` });
}
listTriggerableWorkflows(companyId: string, triggerType: string): WorkflowPipelineRecord[] {
return this.readAll<SqliteRow>(
"select id, company_id as companyId, name, description, steps_json as stepsJson, trigger_type as triggerType, trigger_config_json as triggerConfigJson, status, current_step_index as currentStepIndex, run_count as runCount, last_run_at as lastRunAt, created_at as createdAt, updated_at as updatedAt from workflow_pipelines where company_id = ? and trigger_type = ? and status = 'active' order by created_at asc",
@@ -3987,6 +4210,123 @@ export class AppDatabase {
log.info(`Budget migration: converted ${agents.length} agent budget(s) to policy rows`);
},
},
{
version: 19,
name: "add_document_revisions_and_approval_comments",
fn: () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS document_revisions (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
document_id TEXT NOT NULL,
revision_number INTEGER NOT NULL,
body TEXT NOT NULL,
change_summary TEXT,
created_by_agent_id TEXT,
created_by_user_id TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_doc_rev_doc_num ON document_revisions(document_id, revision_number);
CREATE INDEX IF NOT EXISTS idx_doc_rev_company ON document_revisions(company_id, document_id, created_at);
CREATE TABLE IF NOT EXISTS approval_comments (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
approval_id TEXT NOT NULL,
author_agent_id TEXT,
author_user_id TEXT,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_approval_comments_approval ON approval_comments(approval_id, created_at);
`);
// Add latest_revision_id to documents (may already exist via ensureOptionalColumns)
try { this.db.prepare("ALTER TABLE documents ADD COLUMN latest_revision_id TEXT").run(); } catch { /* column already exists */ }
},
},
{
version: 20,
name: "add_task_timestamps_and_task_approval_links",
fn: () => {
const addCol = (sql: string) => { try { this.db.prepare(sql).run(); } catch { /* already exists */ } };
addCol("ALTER TABLE tasks ADD COLUMN started_at TEXT");
addCol("ALTER TABLE tasks ADD COLUMN completed_at TEXT");
addCol("ALTER TABLE tasks ADD COLUMN cancelled_at TEXT");
this.db.exec(`
CREATE TABLE IF NOT EXISTS task_approval_links (
task_id TEXT NOT NULL,
approval_id TEXT NOT NULL,
company_id TEXT NOT NULL,
linked_by_agent_id TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (task_id, approval_id),
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_task_approval_links_task ON task_approval_links(task_id);
CREATE INDEX IF NOT EXISTS idx_task_approval_links_approval ON task_approval_links(approval_id);
`);
// Backfill: set started_at for tasks already in_progress/in_review/done
this.db.prepare(
"UPDATE tasks SET started_at = updated_at WHERE started_at IS NULL AND status IN ('in_progress', 'in_review', 'done')",
).run();
this.db.prepare(
"UPDATE tasks SET completed_at = updated_at WHERE completed_at IS NULL AND status = 'done'",
).run();
this.db.prepare(
"UPDATE tasks SET cancelled_at = updated_at WHERE cancelled_at IS NULL AND status = 'cancelled'",
).run();
},
},
{
version: 21,
name: "add_work_products_task_documents_wakeup_context",
fn: () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS work_products (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
project_id TEXT,
task_id TEXT NOT NULL,
type TEXT NOT NULL,
provider TEXT NOT NULL,
external_id TEXT,
title TEXT NOT NULL,
url TEXT,
status TEXT NOT NULL DEFAULT 'active',
review_state TEXT NOT NULL DEFAULT 'none',
is_primary INTEGER NOT NULL DEFAULT 0,
summary TEXT,
metadata_json TEXT,
created_by_run_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_work_products_task ON work_products(company_id, task_id, type);
CREATE INDEX IF NOT EXISTS idx_work_products_updated ON work_products(company_id, updated_at);
CREATE TABLE IF NOT EXISTS task_documents (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
task_id TEXT NOT NULL,
document_id TEXT NOT NULL,
key TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_task_key ON task_documents(company_id, task_id, key);
CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_doc ON task_documents(document_id);
`);
// Add context_snapshot column to wakeup_requests for rich context merging
try { this.db.prepare("ALTER TABLE wakeup_requests ADD COLUMN context_snapshot TEXT").run(); } catch { /* already exists */ }
},
},
];
for (const migration of migrations) {
+33 -2
View File
@@ -6,6 +6,7 @@ import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
nativeImage,
nativeTheme,
@@ -153,7 +154,13 @@ export function createDesktopShellService({
callback(false);
});
app.on("web-contents-created", (_event, contents) => {
contents.setWindowOpenHandler(() => ({ action: "deny" }));
contents.setWindowOpenHandler(({ url }) => {
// Allow external https URLs to open in the user's default browser
if (url.startsWith("https://") || url.startsWith("http://")) {
shell.openExternal(url);
}
return { action: "deny" };
});
contents.on("will-attach-webview", (event) => {
event.preventDefault();
});
@@ -198,6 +205,8 @@ export function createDesktopShellService({
async function createWindow() {
const windowIcon = nativeImage.createFromPath(iconPath);
const savedWindowState = loadWindowState(windowStatePath);
const isWindows = process.platform === "win32";
const isLinux = process.platform === "linux";
mainWindow = new BrowserWindow({
title: "AgentCompany",
icon: windowIcon.isEmpty() ? undefined : windowIcon,
@@ -208,7 +217,9 @@ export function createDesktopShellService({
minWidth: 1180,
minHeight: 760,
backgroundColor: "#ffffff",
frame: process.platform === "darwin",
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
...(isWindows || isLinux ? { titleBarOverlay: false } : {}),
webPreferences: {
preload: preloadPath,
contextIsolation: true,
@@ -367,7 +378,15 @@ export function createDesktopShellService({
],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
const menu = Menu.buildFromTemplate(template);
if (process.platform === "darwin") {
Menu.setApplicationMenu(menu);
} else {
// Frameless windows: hide native menu bar but keep keyboard accelerators active
Menu.setApplicationMenu(menu);
mainWindow?.setMenuBarVisibility(false);
mainWindow?.setAutoHideMenuBar(true);
}
}
function createTray() {
@@ -456,6 +475,18 @@ export function createDesktopShellService({
registerProtocol();
buildMenu();
createTray();
// Window control IPC for custom title bar (Windows/Linux frameless windows)
ipcMain.handle("window:minimize", () => { mainWindow?.minimize(); });
ipcMain.handle("window:maximize", () => {
if (mainWindow?.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow?.maximize();
}
});
ipcMain.handle("window:close", () => { mainWindow?.close(); });
ipcMain.handle("window:isMaximized", () => mainWindow?.isMaximized() ?? false);
}
return {
-2
View File
@@ -5,7 +5,6 @@ import { delimiter as pathDelimiter, join } from "node:path";
import { app } from "electron";
interface HostEnvironmentDependencies {
iconPath: string;
logger: {
info: (...args: unknown[]) => void;
};
@@ -53,7 +52,6 @@ function enrichWindowsPath(logger: HostEnvironmentDependencies["logger"]) {
}
export async function prepareHostEnvironment({
iconPath,
logger,
}: HostEnvironmentDependencies) {
if (process.platform === "win32") {
+19 -1
View File
@@ -1,8 +1,10 @@
import { execSync } from "node:child_process";
import {
IPC_CHANNELS,
companyInputSchema,
connectorInputSchema,
deleteEntitySchema,
openTerminalCommandSchema,
secretInputSchema,
} from "@shared/contracts";
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
@@ -14,9 +16,10 @@ export function registerCoreAdminHandlers({
publishDomainChanged,
checkConnector,
openConnectorAuthTerminal,
openGenericTerminal,
}: Pick<
RegisterCoreHandlersDependencies,
"registerHandle" | "db" | "ensureCompanyExists" | "publishDomainChanged" | "checkConnector" | "openConnectorAuthTerminal"
"registerHandle" | "db" | "ensureCompanyExists" | "publishDomainChanged" | "checkConnector" | "openConnectorAuthTerminal" | "openGenericTerminal"
>) {
registerHandle(IPC_CHANNELS.saveCompany, async (_event, payload) => {
const parsed = companyInputSchema.parse(payload);
@@ -80,6 +83,21 @@ export function registerCoreAdminHandlers({
return ok(true);
});
registerHandle(IPC_CHANNELS.checkNodeInstalled, async () => {
try {
const raw = execSync("node --version", { encoding: "utf8", timeout: 10000 }).trim();
return ok({ installed: true, version: raw || null });
} catch {
return ok({ installed: false, version: null });
}
});
registerHandle(IPC_CHANNELS.openTerminalCommand, async (_event, payload) => {
const parsed = openTerminalCommandSchema.parse(payload);
await openGenericTerminal(parsed);
return ok(true);
});
registerHandle(IPC_CHANNELS.saveSecret, async (_event, payload) => {
const parsed = secretInputSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId);
+4 -3
View File
@@ -1,9 +1,9 @@
import type { IpcMainInvokeEvent } from "electron";
import type { DesktopResult } from "@shared/contracts";
import type { SectionId } from "@shared/types";
import type { DesktopEvent, SectionId } from "@shared/types";
import type { AppDatabase } from "./database";
export type CompanyScopedTable = "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets";
type CompanyScopedTable = "workspaces" | "agents" | "goals" | "projects" | "tasks" | "approvals" | "runs" | "secrets";
export interface RegisterCoreHandlersDependencies {
registerHandle: (
@@ -17,7 +17,7 @@ export interface RegisterCoreHandlersDependencies {
refs: Array<{ id: string | null | undefined; table: CompanyScopedTable; label: string }>,
) => { id: string | null | undefined; table: CompanyScopedTable; label: string } | null;
publishDomainChanged: () => void;
emitEvent: (event: any) => void;
emitEvent: (event: DesktopEvent) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
handleTaskCreated: (taskId: string, companyId: string) => void;
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
@@ -26,6 +26,7 @@ export interface RegisterCoreHandlersDependencies {
queueTaskRun: (taskId: string, trigger: string, actor: string) => DesktopResult<string>;
checkConnector: (id: any) => Promise<void>;
openConnectorAuthTerminal: (id: any) => Promise<boolean>;
openGenericTerminal: (input: { title: string; command: string }) => Promise<boolean>;
checkForUpdates: () => Promise<boolean>;
getWorkerProcess: () => { postMessage: (message: unknown) => void } | null;
resetAfterRestore: () => void;
+3 -2
View File
@@ -2,11 +2,12 @@ import type { IpcMainInvokeEvent } from "electron";
import { z } from "zod";
import type { DesktopResult } from "@shared/contracts";
import type { bootstrapOnboardingSchema } from "@shared/contracts";
import type { DesktopEvent } from "@shared/types";
import type { AppDatabase } from "./database";
import type { createSkillService } from "./skill-service";
import type { createBudgetEnforcementService } from "./budget-enforcement-service";
export interface BrowserActionControl {
interface BrowserActionControl {
clearSocialAccountData: (socialAccountId: string, screenshotPaths?: string[]) => Promise<void>;
cancelAction: (actionId: string) => boolean;
launchLoginBrowser: (socialAccountId: string, platform: string, loginUrl?: string) => Promise<{
@@ -33,7 +34,7 @@ export interface RegisterDomainHandlersDependencies {
refs: Array<{ id: string | null | undefined; table: string; label: string }>,
) => { id: string | null | undefined; table: string; label: string } | null;
publishDomainChanged: () => void;
emitEvent: (event: any) => void;
emitEvent: (event: DesktopEvent) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
dispatchAutomation: (trigger: string, ctx: { companyId: string; [key: string]: unknown }) => void;
runWorkflowPipeline: (workflowId: string, companyId: string) => Promise<void>;
+9 -1
View File
@@ -2,7 +2,7 @@ import log from "electron-log/main.js";
import type { AgentApiServer } from "./api-server";
import { initializeAgentTokenSigningKey } from "./agent-auth";
import { bootstrapApplication } from "./bootstrap-application";
import { openConnectorAuthTerminal as openConnectorAuthTerminalImpl } from "./connector-auth-service";
import { openConnectorAuthTerminal as openConnectorAuthTerminalImpl, openGenericTerminal as openGenericTerminalImpl } from "./connector-auth-service";
import { getConnectorDefinition } from "./connectors";
import type { AppDatabase } from "./database";
import { seedDefaultAutomationRules as seedDefaultAutomationRulesImpl } from "./default-automation-rules";
@@ -138,6 +138,7 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
const connector = requireDb().getConnector(id);
return openConnectorAuthTerminalImpl(id, connector.command);
},
openGenericTerminal: (input) => openGenericTerminalImpl(input),
checkForUpdates: () => requireUpdaterService().checkForUpdates(),
getWorkerProcess: () => requireServiceGraph().getWorkerService()?.getWorker() ?? null,
resetAfterRestore: () => {
@@ -147,6 +148,13 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
getApiPort: () => state.apiServer?.getPort() ?? 0,
notify: (options) => requireIpcSupport().notify(options),
listConnectorModels: async (connectorId) => {
// For claude_compatible, return user-configured models from the DB
// (stored as comma-separated values in the connector's model field)
if (connectorId.startsWith("claude_compat")) {
const connector = requireDb().getConnector(connectorId);
const raw = connector.model ?? "";
return raw.split(",").map((m) => m.trim()).filter(Boolean).map((m) => ({ id: m, label: m }));
}
const definition = getConnectorDefinition(connectorId as Parameters<typeof getConnectorDefinition>[0]);
return definition.listModels ? definition.listModels() : [];
},
+9 -1
View File
@@ -21,6 +21,7 @@ interface BootstrapInput {
goalTitle: string;
goalDescription: string;
connectorId?: ConnectorId;
connectorModel?: string;
}
interface OnboardingDependencies {
@@ -32,6 +33,8 @@ interface OnboardingDependencies {
error?: { code: string; message: string };
};
publishDomainChanged: () => void;
/** Scan workspace directories for skills and import them. Best-effort — may be undefined during early init. */
scanAndAssignSkills?: (companyId: string, agentId: string) => Promise<void>;
}
const DEFERRABLE_RUN_ERRORS = new Set([
@@ -69,6 +72,7 @@ export function createOnboardingService({
seedDefaultAutomationRules,
queueTaskRun,
publishDomainChanged,
scanAndAssignSkills,
}: OnboardingDependencies) {
function updateAgentWorkspaceBinding(agentId: string, workspaceId: string | null) {
if (!workspaceId) return;
@@ -217,7 +221,7 @@ export function createOnboardingService({
reportsTo: null,
connectorId: selectedConnectorId,
workspaceId,
model: null,
model: input.connectorModel?.trim() || null,
capabilities:
"Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
budgetMonthlyUsd: 250,
@@ -227,6 +231,10 @@ export function createOnboardingService({
db.setCurrentCompany(companyId);
seedDefaultAutomationRules(companyId);
if (scanAndAssignSkills) {
void scanAndAssignSkills(companyId, ceoId).catch(() => {});
}
const kickoff = createInitialExecutionLoop(companyId, workspaceId);
db.addActivity({
+36 -41
View File
@@ -68,9 +68,13 @@ interface OrchestrationDependencies {
hasWorkerReady: () => boolean;
enqueueWorkerRun: (payload: EnqueueRunPayload) => void;
isQuitting: () => boolean;
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => Array<{ runtimeName: string; sourcePath: string }>;
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => Array<{ runtimeName: string; sourcePath: string; name?: string }>;
budgetService: ReturnType<typeof createBudgetEnforcementService>;
getSessionHandoff?: (taskId: string) => string | null;
evaluatePreRunSessionRotation?: (taskId: string, connectorId: string) => {
rotated: boolean;
handoff: string | null;
};
}
export function createOrchestrationService({
@@ -88,6 +92,7 @@ export function createOrchestrationService({
listRuntimeSkillEntries,
budgetService,
getSessionHandoff,
evaluatePreRunSessionRotation,
}: OrchestrationDependencies) {
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
const scheduleLastFiredMap = new Map<string, number>();
@@ -132,31 +137,6 @@ export function createOrchestrationService({
return { workspace: null, lockMode: "none", source: "none" };
}
// DEPRECATED: Superseded by BudgetEnforcementService.evaluateBudgetGate (Phase 5)
function autoPauseBudgetExceededAgent(agent: { id: string; companyId: string; name: string; spentMonthlyUsd: number; budgetMonthlyUsd: number }) {
if (!(agent.budgetMonthlyUsd > 0) || agent.spentMonthlyUsd < agent.budgetMonthlyUsd) {
return false;
}
db.updateAgentStatus(agent.id, "paused");
db.addActivity({
companyId: agent.companyId,
actor: "system",
action: "agent.auto_paused",
entityType: "agent",
entityId: agent.id,
detail: `Budget exhausted at ${agent.spentMonthlyUsd.toFixed(2)} / ${agent.budgetMonthlyUsd.toFixed(2)} USD.`,
});
notify({
title: "Agent Paused",
body: `${agent.name} paused -- budget limit reached`,
urgency: "informational",
navigation: { section: "agents", entityId: agent.id },
batchKey: "agent_paused",
});
emitEvent({ type: "agent-updated", agentId: agent.id, action: "paused" });
return true;
}
function ensureAgentHeartbeatEnabled(agentId: string) {
const agent = db.getAgent(agentId);
if (!agent.heartbeatEnabled) {
@@ -246,6 +226,16 @@ export function createOrchestrationService({
}
}
let managedSkills: Array<{ runtimeName: string; sourcePath: string; name?: string }> = [];
try {
managedSkills = listRuntimeSkillEntries(companyId, agent.id, connector.id);
} catch (err) {
logger.warn(`[run] Failed to fetch managed skills for agent ${agent.name}: ${err instanceof Error ? err.message : String(err)}`);
}
const skillNames = managedSkills
.map((s) => s.name ?? s.runtimeName)
.filter(Boolean);
const systemPrompt = apiUrl ? buildTaskPrompt({
agent,
apiUrl,
@@ -298,6 +288,7 @@ export function createOrchestrationService({
.slice(0, 5),
allConnectors: snapshot.connectors.map((c) => ({ id: c.id, label: c.label, status: c.status })),
sessionHandoff,
skillNames,
}) : null;
if (apiUrl) {
@@ -312,12 +303,27 @@ export function createOrchestrationService({
// SESS-01: Prefer task_sessions table, fall back to legacy tasks.session_json
const activeSession = db.getActiveTaskSession(task.id);
const taskSession = activeSession
let taskSession = activeSession
? {
sessionParams: activeSession.sessionParamsJson ? JSON.parse(activeSession.sessionParamsJson) as Record<string, unknown> : null,
sessionDisplayId: activeSession.sessionDisplayId,
}
: db.getTaskSession(task.id);
// Pre-execution session compaction (Paperclip pattern): evaluate BEFORE run starts
if (evaluatePreRunSessionRotation && activeSession) {
const rotationResult = evaluatePreRunSessionRotation(task.id, connector.id);
if (rotationResult.rotated) {
// Session was rotated: clear session params so adapter starts fresh
taskSession = { sessionParams: null, sessionDisplayId: null };
// Handoff will be picked up by getSessionHandoff() above (already loaded from rotated session)
if (!sessionHandoff && rotationResult.handoff) {
sessionHandoff = rotationResult.handoff;
}
logger.info(`[session] Pre-run compaction for task ${task.id}: session rotated before execution`);
}
}
let thinkingEffort: string | null = null;
try {
const meta = JSON.parse(agent.metadataJson || "{}");
@@ -328,14 +334,6 @@ export function createOrchestrationService({
thinkingEffort = null;
}
// Fetch managed skills for this agent -- best-effort, never blocks run dispatch
let managedSkills: Array<{ runtimeName: string; sourcePath: string }> = [];
try {
managedSkills = listRuntimeSkillEntries(companyId, agent.id, connector.id);
} catch (err) {
logger.warn(`[run] Failed to fetch managed skills for agent ${agent.name}: ${err instanceof Error ? err.message : String(err)}`);
}
enqueueWorkerRun({
runId,
companyId,
@@ -521,7 +519,7 @@ export function createOrchestrationService({
return run.id;
}
function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string) {
function wakeAgentIfPossible(agentId: string | null | undefined, companyId: string, trigger: string, context?: Record<string, unknown>) {
if (!agentId || isQuitting()) return null;
try {
const agent = db.getAgent(agentId);
@@ -540,15 +538,13 @@ export function createOrchestrationService({
const agentBusy = hasActiveRun({ agentId, companyId });
if (agentBusy) {
// Agent is busy — enqueue wakeup with lifecycle tracking (D-05)
db.enqueueWakeupRequest(companyId, agentId, trigger, `Wake for agent: ${trigger}`);
db.enqueueWakeupRequest(companyId, agentId, trigger, `Wake for agent: ${trigger}`, context);
return null;
}
const runId = startAgentHeartbeatRun(agentId, companyId, trigger);
if (!runId) {
// Run failed to start but agent not busy — still enqueue for retry
db.enqueueWakeupRequest(companyId, agentId, trigger, `Wake for agent (deferred): ${trigger}`);
db.enqueueWakeupRequest(companyId, agentId, trigger, `Wake for agent (deferred): ${trigger}`, context);
}
return runId;
}
@@ -877,7 +873,6 @@ export function createOrchestrationService({
return {
resolveWorkspace,
autoPauseBudgetExceededAgent,
wakeAgentIfPossible,
dispatchRunToWorker,
queueTaskRun,
+21 -2
View File
@@ -1,4 +1,4 @@
import { runningProcesses } from "@agentcompany/adapter-utils/server-utils";
import { runningProcesses, shutdownAllRunning } from "@agentcompany/adapter-utils/server-utils";
import { getConnectorDefinition } from "@main/connectors";
import { buildSanitizedRuntimeEnv } from "@main/runtime-env";
import type { ConnectorId, RunStatus } from "@shared/types";
@@ -47,7 +47,8 @@ interface RunJob {
type ParentMessage =
| { type: "enqueue-run"; payload: RunJob }
| { type: "cancel-run"; runId: string };
| { type: "cancel-run"; runId: string }
| { type: "shutdown" };
type ChildMessage =
| { type: "run-log"; runId: string; stream: "stdout" | "stderr"; chunk: string }
@@ -114,6 +115,16 @@ function buildConnectorExecutionConfig(job: RunJob, sanitizedEnv: Record<string,
],
};
case "claude_local":
case "claude_compatible":
case "claude_compat_2":
case "claude_compat_3":
case "claude_compat_4":
case "claude_compat_5":
case "claude_compat_6":
case "claude_compat_7":
case "claude_compat_8":
case "claude_compat_9":
case "claude_compat_10":
return {
...baseConfig,
effort: job.thinkingEffort ?? "",
@@ -306,5 +317,13 @@ parentPort.on("message", (event) => {
}
if (message.type === "cancel-run") {
cancelRun(message.runId);
return;
}
if (message.type === "shutdown") {
// Kill every running CLI process (claude, codex, gemini, etc.) and exit.
// This is the only reliable way to ensure child processes don't survive app quit,
// because the main process cannot kill grandchildren directly.
shutdownAllRunning();
process.exit(0);
}
});
+72 -7
View File
@@ -33,6 +33,8 @@ interface PromptContext {
allConnectors?: Array<{ id: string; label: string; status: string }>;
/** D-07: Handoff markdown from a rotated session, injected as context for the agent */
sessionHandoff?: string | null;
/** Names of skills injected into this agent's runtime environment */
skillNames?: string[];
}
export function buildSystemPrompt(ctx: PromptContext): string {
@@ -59,6 +61,7 @@ Today is ${dayOfWeek}, ${dateStr} at ${timeStr} UTC.
You are ${ctx.agent.name}, a ${ctx.agent.role}${ctx.agent.title ? ` (${ctx.agent.title})` : ""} at ${ctx.companyName}.
${ctx.agent.capabilities ? `Your capabilities: ${ctx.agent.capabilities}` : ""}
Your monthly budget: $${ctx.agent.budgetMonthlyUsd.toFixed(2)} | Spent this month: $${ctx.agent.spentMonthlyUsd.toFixed(2)}${ctx.agent.budgetMonthlyUsd > 0 ? ` (${Math.round((ctx.agent.spentMonthlyUsd / ctx.agent.budgetMonthlyUsd) * 100)}% used)` : ""}
${ctx.skillNames && ctx.skillNames.length > 0 ? `Your domain skills: ${ctx.skillNames.join(", ")}. These skills are loaded into your environment and provide domain expertise — use them when relevant to your work.` : ""}
`);
if (ctx.companyDescription) {
@@ -711,6 +714,54 @@ Body: {
### List Company Documents
GET /api/companies/${ctx.agent.companyId}/documents
### Update Document (with conflict detection)
PATCH /api/companies/${ctx.agent.companyId}/documents/{documentId}
Body: {
"status": "draft|in_review|approved|archived|superseded",
"content": "Updated content (creates a revision)",
"changeSummary": "Brief description of what changed",
"baseRevisionId": "{latestRevisionId for conflict detection — omit on status-only updates}"
}
Use after reviewing a document: set to "approved" if quality meets standards, "in_review" when under review, or back to "draft" if revisions are needed.
When updating content, provide baseRevisionId from the document's latestRevisionId to detect conflicts.
### List Document Revisions
GET /api/companies/${ctx.agent.companyId}/documents/{documentId}/revisions
### Comment on Approvals
POST /api/approvals/{approvalId}/comments
Body: { "body": "Your comment text. Use @AgentName to notify specific agents." }
### Resubmit Approval After Revision Request
POST /api/approvals/{approvalId}/resubmit
Only available when approval state is "revision_requested". Resets to "pending" for re-review.
### Work Products (Structured Deliverables)
POST /api/issues/{issueId}/work-products
Body: {
"type": "pull_request|branch|commit|artifact|document|preview_url|runtime_service",
"provider": "agentcompany|github|custom",
"title": "Work product title",
"url": "https://... (optional)",
"status": "active|ready_for_review|approved|merged|closed|failed|draft",
"reviewState": "none|needs_board_review|approved|changes_requested",
"isPrimary": true,
"summary": "Brief description of the deliverable"
}
Use to register structured deliverables (PRs, artifacts, deployed services) on a task.
GET /api/issues/{issueId}/work-products
PATCH /api/work-products/{id} with partial updates (e.g. {"reviewState": "approved"})
DELETE /api/work-products/{id}
### Link Document to Task (Semantic Classification)
PUT /api/issues/{issueId}/documents/{key}
Body: { "documentId": "{existingDocumentId}" }
Key must be lowercase alphanumeric with _ or - (e.g. "plan", "spec", "design", "test-plan").
Links a document to a task under a semantic key. One document per key per task.
GET /api/issues/{issueId}/documents — list all linked documents for a task
### Add Knowledge Entry (lessons learned, best practices)
POST /api/companies/${ctx.agent.companyId}/knowledge
Body: {
@@ -989,15 +1040,29 @@ If there are NO active goals, this is your top priority — create strategic goa
-d '{"title":"Goal title","description":"Measurable success criteria","ownerAgentId":"${ctx.agent.id}","status":"active"}'
Then decompose each goal into projects, and each project into tasks assigned to department leads.
## Priority 2: Review Completed Work (SOP-004)
## Priority 2: Ensure All Agents Have Skills (SOP-002b)
Every agent needs domain skills to produce quality work. Check and fix immediately:
1. For EACH of your direct reports, check their skills: GET /api/agents/{agentId}/skills
2. If an agent has ZERO skills assigned, this is urgent — assign skills NOW:
a. GET /api/companies/${ctx.agent.companyId}/skills to list available skills
b. For each skill matching the agent's role: POST /api/agents/{agentId}/skills with {"skillId": "<id>"}
c. Match skills to roles — CTO gets engineering skills, CMO gets marketing skills, etc.
3. This MUST happen before delegating any work — agents without skills produce poor output
## Priority 3: Review Completed Work (SOP-004)
When tasks from your reports have status "in_review":
- Read ALL comments on the task to understand what was done
- If satisfactory: PATCH /api/issues/{issueId} with status "done" + approving comment
- If changes needed: PATCH /api/issues/{issueId} with status "in_progress" + detailed, specific feedback
- Review any documents the agent created: GET /api/companies/${ctx.agent.companyId}/documents
- If satisfactory:
- PATCH /api/issues/{issueId} with status "done" + approving comment
- Update document status: PATCH /api/companies/${ctx.agent.companyId}/documents/{documentId} with {"status": "approved"}
- If changes needed:
- PATCH /api/issues/{issueId} with status "in_progress" + detailed, specific feedback
- Update document status: PATCH /api/companies/${ctx.agent.companyId}/documents/{documentId} with {"status": "draft"}
- This is YOUR HIGHEST PRIORITY after standup — reviews unblock your entire team
- NEVER let review requests sit — they cascade-block everyone downstream
## Priority 3: Goal Decomposition & Delegation (SOP-003)
## Priority 4: Goal Decomposition & Delegation (SOP-003)
When you have active goals without projects or tasks:
1. Create a project for each major workstream:
POST /api/companies/${ctx.agent.companyId}/projects with goalId, leadAgentId, description
@@ -1018,14 +1083,14 @@ When you have active goals without projects or tasks:
6. Wake the assigned agents so they start immediately:
POST /api/agents/{agentId}/wake with reason explaining the new assignment
## Priority 4: Unblock & Coordinate
## Priority 5: Unblock & Coordinate
- Review blocked tasks: read the blocker comments and take action
- For credential needs: create secret_access approvals via POST /api/companies/${ctx.agent.companyId}/approvals
- For cross-department dependencies: send direct messages to the relevant department lead
- For Board-level decisions: create an approval with clear context and options
- Review failed runs from your reports: check what went wrong and decide whether to retry or reassign
## Priority 5: Grow the Team
## Priority 6: Grow the Team
When workload exceeds capacity or new capabilities are needed:
- Assess: count open tasks vs active agents per department
- If a department is overloaded: propose hiring via POST /api/companies/${ctx.agent.companyId}/agents
@@ -1033,7 +1098,7 @@ When workload exceeds capacity or new capabilities are needed:
- Each hire needs: clear role, specific capabilities, budget justification, department assignment
- After hire approval, create onboarding tasks for the new agent
## Priority 6: Strategic Communication & Reporting
## Priority 7: Strategic Communication & Reporting
- Schedule all-hands meetings when major milestones are reached:
POST /api/companies/${ctx.agent.companyId}/meetings with type "all_hands"
- Create status_report documents summarizing company progress:
+22 -1
View File
@@ -2,7 +2,7 @@ import { app } from "electron";
import type { AgentApiServer } from "./api-server";
import type { AppDatabase } from "./database";
import { BrowserManager } from "./browser-manager";
import { createAutomationService } from "./automation-service";
import { createAutomationService, autoAssignSkillsToAgent } from "./automation-service";
import { createBrowserActionService } from "./browser-action-service";
import { createConnectorHealthService } from "./connector-health-service";
import { createDesktopShellService } from "./desktop-shell-service";
@@ -241,6 +241,22 @@ export function createServiceGraph({
return null;
}
},
evaluatePreRunSessionRotation: (taskId: string, connectorId: string) => {
try {
const sessionSvc = getOrCreateSessionManagementService();
const activeSession = db.getActiveTaskSession(taskId);
if (!activeSession) return { rotated: false, handoff: null };
const result = sessionSvc.evaluateRotation(activeSession, connectorId);
if (result.shouldRotate) {
sessionSvc.rotateSession(activeSession);
const handoff = sessionSvc.getHandoffContext(taskId);
return { rotated: true, handoff };
}
return { rotated: false, handoff: null };
} catch {
return { rotated: false, handoff: null };
}
},
});
workerService.createWorker();
@@ -260,6 +276,11 @@ export function createServiceGraph({
seedDefaultAutomationRules,
queueTaskRun,
publishDomainChanged,
scanAndAssignSkills: async (companyId: string, agentId: string) => {
const svc = getOrCreateSkillService();
await svc.scanWorkspaceSkills(companyId);
autoAssignSkillsToAgent(db, logger, companyId, agentId);
},
});
browserActionService = createBrowserActionService({
-13
View File
@@ -1,15 +1,6 @@
// ---------------------------------------------------------------------------
// SKILL.md format parser, inventory classifier, and trust level derivation
// ---------------------------------------------------------------------------
import { extname } from "node:path";
import type { FileInventoryEntry, TrustLevel } from "./skill-types.js";
// ---------------------------------------------------------------------------
// Minimal YAML frontmatter parsing (no external YAML library)
// Follows the Paperclip reference pattern for SKILL.md files.
// ---------------------------------------------------------------------------
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -137,10 +128,6 @@ function parseYamlFrontmatter(raw: string): Record<string, unknown> {
return isPlainRecord(parsed.value) ? parsed.value : {};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Parse a SKILL.md file with optional YAML frontmatter.
* Normalizes CRLF to LF before parsing.
-14
View File
@@ -1,9 +1,3 @@
// ---------------------------------------------------------------------------
// GitHub REST API skill fetcher -- imports skills without git clone (D-02)
// Public repos only (D-03). Uses raw.githubusercontent.com for content
// downloads (does not count against API rate limits per research).
// ---------------------------------------------------------------------------
/**
* Data returned for each skill found in a GitHub repository.
*/
@@ -21,10 +15,6 @@ interface Logger {
error: (...args: unknown[]) => void;
}
// ---------------------------------------------------------------------------
// Commit SHA resolution
// ---------------------------------------------------------------------------
/**
* Resolve the latest commit SHA for a given ref (branch, tag, or HEAD).
* Returns the 40-character hex SHA on success, null on error.
@@ -61,10 +51,6 @@ export async function resolveGitHubCommitSha(
}
}
// ---------------------------------------------------------------------------
// GitHub tree + SKILL.md fetcher
// ---------------------------------------------------------------------------
/**
* Fetch all SKILL.md files from a GitHub repository via the REST API.
*
-20
View File
@@ -1,15 +1,7 @@
// ---------------------------------------------------------------------------
// Canonical skill key derivation and skill import source resolution
// ---------------------------------------------------------------------------
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import type { SkillSourceType } from "./skill-types.js";
// ---------------------------------------------------------------------------
// Slug normalization helpers
// ---------------------------------------------------------------------------
const SLUG_DELIM_RE = /[^a-z0-9]+/g;
const SLUG_TRIM_RE = /^-+|-+$/g;
@@ -30,10 +22,6 @@ export function normalizeSkillSlug(
return normalized.length > 0 ? normalized : null;
}
// ---------------------------------------------------------------------------
// Hashing
// ---------------------------------------------------------------------------
/**
* Compute the first 10 characters of a SHA-256 hex digest.
* Used for hash segments in canonical keys.
@@ -42,10 +30,6 @@ export function hashSkillValue(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 10);
}
// ---------------------------------------------------------------------------
// Canonical key derivation
// ---------------------------------------------------------------------------
function asString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
@@ -113,10 +97,6 @@ export function deriveCanonicalSkillKey(
return `company/${companyId}/${slug}`;
}
// ---------------------------------------------------------------------------
// Skill import source resolution
// ---------------------------------------------------------------------------
// Three-part key: org/repo/skill
const THREE_PART_KEY_RE =
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
+1 -40
View File
@@ -1,7 +1,5 @@
// ---------------------------------------------------------------------------
// SkillService -- full skill lifecycle: import, CRUD, assignment, token budget,
// update detection. Factory pattern following createOrchestrationService.
// ---------------------------------------------------------------------------
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
@@ -35,10 +33,7 @@ import {
} from "./skill-github.js";
import { TOKEN_BUDGETS } from "./skill-types.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface SkillServiceDependencies {
db: AppDatabase;
profileDir: string;
@@ -49,10 +44,7 @@ interface SkillServiceDependencies {
};
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createSkillService({ db, profileDir, logger }: SkillServiceDependencies) {
// ---- Result helpers ----
function ok<T>(data: T): DesktopResult<T> {
@@ -75,8 +67,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
return join(skillsRoot(companyId), "__runtime__");
}
// ---- File materialization helpers ----
/**
* Write SKILL.md (and tracked inventory files from GitHub downloads)
* to the local skill directory.
@@ -101,8 +91,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
}
}
// ---- GitHub URL parser ----
function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
try {
const parsed = new URL(url);
@@ -118,8 +106,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
}
}
// ---- Import routing ----
/**
* Import skills from any supported source.
* Routes to the correct handler based on parseSkillImportSource.
@@ -178,8 +164,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
}
}
// ---- GitHub import (SKILL-01) ----
async function importFromGitHub(
companyId: string,
url: string,
@@ -296,8 +280,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
return results;
}
// ---- Local path import (SKILL-02, D-04) ----
async function importFromLocal(
companyId: string,
localPath: string,
@@ -416,8 +398,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
return results;
}
// ---- URL import (SKILL-03, D-05) ----
async function importFromUrl(
companyId: string,
url: string,
@@ -487,8 +467,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
return results;
}
// ---- CRUD ----
function listForCompany(companyId: string): SkillRecord[] {
return db.listCompanySkills(companyId);
}
@@ -522,8 +500,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
}
}
// ---- Agent assignment (SKILL-07) ----
function assignToAgent(
companyId: string,
agentId: string,
@@ -544,8 +520,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
return ok(undefined);
}
// ---- Update detection (SKILL-11, D-11) ----
async function checkUpdateStatus(
companyId: string,
skillId: string,
@@ -603,8 +577,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
});
}
// ---- Runtime skill entries (SKILL-09 token budget) ----
/**
* Build the list of RuntimeSkillEntry for adapter injection.
* Applies token budget enforcement: skills sorted by creation date,
@@ -617,12 +589,7 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
): RuntimeSkillEntry[] {
const allSkills = db.listCompanySkills(companyId);
const agentAssignments = db.listAgentSkillAssignments(agentId);
// If agent has specific assignments, filter to those; otherwise use all
const skills =
agentAssignments.length > 0
? allSkills.filter((s) => agentAssignments.includes(s.id))
: allSkills;
const skills = allSkills.filter((s) => agentAssignments.includes(s.id));
// Sort by creation date (oldest first -- first imported gets priority)
const sorted = [...skills].sort(
@@ -653,8 +620,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
return entries;
}
// ---- Local directory walker ----
function findSkillMdFiles(rootDir: string): string[] {
const found: string[] = [];
function walk(dir: string) {
@@ -701,8 +666,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
return inventory;
}
// ---- Workspace .agents/skills/ scanning (Bug 6) ----
/**
* Scan all workspace local paths for a company, looking for
* `.agents/skills/` directories containing SKILL.md files.
@@ -761,8 +724,6 @@ export function createSkillService({ db, profileDir, logger }: SkillServiceDepen
return allResults;
}
// ---- Public API ----
return {
importFromSource,
importFromGitHub,
-4
View File
@@ -1,7 +1,3 @@
// ---------------------------------------------------------------------------
// Skill system type definitions and constants
// ---------------------------------------------------------------------------
/**
* Source type for a skill -- matches the company_skills.source_type column.
*/
-19
View File
@@ -5,10 +5,6 @@
* minimum description quality, acceptance criteria, and capability matching.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TaskValidationResult {
valid: boolean;
errors: string[];
@@ -28,9 +24,6 @@ export interface CapabilityMatchDb {
getCompanySkill?(companyId: string, skillId: string): { name: string; description: string | null; slug: string } | null;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MIN_DESCRIPTION_LENGTH = 50;
@@ -49,9 +42,6 @@ export const ACCEPTANCE_CRITERIA_KEYWORDS: readonly string[] = [
"exit criteria",
];
// ---------------------------------------------------------------------------
// checkAcceptanceCriteria
// ---------------------------------------------------------------------------
export function checkAcceptanceCriteria(description: string): { hasCriteria: boolean; suggestion: string } {
const lower = description.toLowerCase();
@@ -68,9 +58,6 @@ export function checkAcceptanceCriteria(description: string): { hasCriteria: boo
};
}
// ---------------------------------------------------------------------------
// validateTaskCreation
// ---------------------------------------------------------------------------
export function validateTaskCreation(
input: { title: string; description: string },
@@ -102,9 +89,6 @@ export function validateTaskCreation(
return { valid: errors.length === 0, errors, warnings };
}
// ---------------------------------------------------------------------------
// checkCapabilityMatch
// ---------------------------------------------------------------------------
/** Tokenize text into words >3 chars, same pattern as database.ts autoAssignTask */
function tokenize(text: string): string[] {
@@ -162,9 +146,6 @@ export function checkCapabilityMatch(
};
}
// ---------------------------------------------------------------------------
// TASK-04: Stale Review Escalation
// ---------------------------------------------------------------------------
export const REVIEW_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes (D-08)
+33 -12
View File
@@ -498,25 +498,46 @@ export function createWorkerService({
pendingRetryTimers.clear();
if (worker) {
// Cancel all running/queued runs in the worker before killing it.
// This tells the worker to SIGTERM all child CLI processes (claude, codex, etc.)
const snapshot = db.listSnapshot();
for (const run of snapshot.runs) {
if (run.status === "running" || run.status === "queued") {
try {
worker.postMessage({ type: "cancel-run", runId: run.id });
} catch { /* worker may already be dead */ }
}
}
// Tell the worker to kill all its child CLI processes and exit.
// Previous approach sent individual cancel-run messages then immediately
// killed the worker — a race condition that left CLI processes as orphans
// because the worker died before it could forward SIGTERM to its children.
try {
worker.postMessage({ type: "shutdown" });
} catch { /* worker may already be dead */ }
worker.kill();
// Force-kill after 2s if SIGTERM didn't work (child CLI processes may keep it alive)
// Safety net: force-kill worker after 2s if shutdown didn't complete
const ref = worker;
setTimeout(() => {
try { ref.kill(); } catch { /* already dead */ }
}, 2000);
worker = null;
}
// Mark all running/queued runs as interrupted so DB state is clean on restart
try {
const snapshot = db.listSnapshot();
for (const run of snapshot.runs) {
if (run.status === "running" || run.status === "queued") {
db.finishRun({
runId: run.id,
status: "interrupted",
summary: "Application shut down.",
errorMessage: null,
exitCode: null,
signal: null,
model: null,
sessionDisplayId: null,
costUsd: null,
unattributedCost: true,
});
if (run.workspaceId) {
db.releaseWorkspaceLock(run.workspaceId, run.id);
}
db.releaseTaskLock(run.taskId, run.id);
}
}
} catch { /* best effort during shutdown */ }
}
return {
+7
View File
@@ -51,6 +51,8 @@ const api: DesktopApi = {
getAgentMetrics: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgentMetrics, input),
getStandupReport: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getStandupReport, companyId),
bootstrapOnboarding: (input) => ipcRenderer.invoke(IPC_CHANNELS.bootstrapOnboarding, input),
checkNodeInstalled: () => ipcRenderer.invoke(IPC_CHANNELS.checkNodeInstalled),
openTerminalCommand: (input) => ipcRenderer.invoke(IPC_CHANNELS.openTerminalCommand, input),
saveSocialAccount: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveSocialAccount, input),
deleteSocialAccount: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSocialAccount, input),
listBrowserActions: (input) => ipcRenderer.invoke(IPC_CHANNELS.listBrowserActions, input),
@@ -118,6 +120,11 @@ const api: DesktopApi = {
ipcRenderer.removeListener(IPC_CHANNELS.rendererEvent, listener);
};
},
platform: process.platform as "win32" | "darwin" | "linux",
windowMinimize: () => ipcRenderer.invoke("window:minimize"),
windowMaximize: () => ipcRenderer.invoke("window:maximize"),
windowClose: () => ipcRenderer.invoke("window:close"),
windowIsMaximized: () => ipcRenderer.invoke("window:isMaximized") as Promise<boolean>,
};
contextBridge.exposeInMainWorld("agentCompany", api);
+49 -31
View File
@@ -5,7 +5,6 @@ import {
ChatCircle,
Check,
Command,
Cpu,
FileText,
FolderOpen,
Gear,
@@ -53,6 +52,7 @@ import {
PendingReviewList,
SecretCreator,
SettingsPane,
ModelManagement,
TaskCreator,
TaskInspector,
TaskQueue,
@@ -81,6 +81,7 @@ import { HiringPipeline } from "./components/hiring";
import { SkillsSection } from "./components/skills";
import { DocumentCenter, KnowledgeBaseManager, MeetingsManager, SprintManager } from "./components/company-operations";
import { ActionButton, ErrorBoundary, ListFrame, ListRow, Modal, Panel, SectionButton, Select, StatusPill, useFocusTrap, useReducedMotion } from "./components/ui";
import TitleBar from "./components/TitleBar";
import ToastContainer from "./components/Toast";
import { unwrap } from "./lib/desktop";
import { formatMoney, formatTime } from "./lib/formatters";
@@ -688,13 +689,18 @@ export function App() {
}
}, [refresh]);
const showCustomTitleBar = window.agentCompany.platform !== "darwin";
if (!snapshot) {
return (
<LocaleContext.Provider value={locale}>
<TranslationContext.Provider value={t}>
<div className="flex min-h-screen flex-col items-center justify-center gap-3">
<CircleNotch size={24} className="animate-spin text-primary" />
<span className="text-[13px] text-muted-foreground">{t("app.loading")}</span>
<div className="flex h-[100dvh] flex-col overflow-hidden">
{showCustomTitleBar && <TitleBar />}
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3">
<CircleNotch size={24} className="animate-spin text-primary" />
<span className="text-[13px] text-muted-foreground">{t("app.loading")}</span>
</div>
</div>
</TranslationContext.Provider>
</LocaleContext.Provider>
@@ -705,31 +711,40 @@ export function App() {
return (
<LocaleContext.Provider value={locale}>
<TranslationContext.Provider value={t}>
<CompanyOnboarding
connectors={snapshot.connectors}
onDone={handleOnboardingDone}
canCancel={snapshot.companies.length > 0 && !initialOnboardingLocked}
onCancel={() => {
setShowOnboarding(false);
setInitialOnboardingLocked(false);
setError(null);
}}
currentLocale={locale}
onLocaleChange={handleLocaleChange}
isFirstLaunch={snapshot.companies.length === 0}
/>
<div className="flex h-[100dvh] flex-col overflow-hidden">
{showCustomTitleBar && <TitleBar />}
<div className="min-h-0 flex-1 overflow-auto">
<CompanyOnboarding
connectors={snapshot.connectors}
onDone={handleOnboardingDone}
canCancel={snapshot.companies.length > 0 && !initialOnboardingLocked}
onCancel={() => {
setShowOnboarding(false);
setInitialOnboardingLocked(false);
setError(null);
}}
currentLocale={locale}
onLocaleChange={handleLocaleChange}
isFirstLaunch={snapshot.companies.length === 0}
/>
</div>
</div>
</TranslationContext.Provider>
</LocaleContext.Provider>
);
}
const isMac = !showCustomTitleBar;
return (
<LocaleContext.Provider value={locale}>
<TranslationContext.Provider value={t}>
<div className={`grid h-[100dvh] overflow-hidden bg-background ${sidebarCollapsed ? "grid-cols-[56px_minmax(0,1fr)]" : "grid-cols-[220px_minmax(0,1fr)]"}`}>
<aside className="flex h-[100dvh] flex-col overflow-hidden border-r border-border bg-card">
<div className="flex h-[100dvh] flex-col overflow-hidden bg-background">
{showCustomTitleBar && <TitleBar />}
<div className={`grid min-h-0 flex-1 overflow-hidden ${sidebarCollapsed ? "grid-cols-[56px_minmax(0,1fr)]" : "grid-cols-[220px_minmax(0,1fr)]"}`}>
<aside className="flex h-full flex-col overflow-hidden border-r border-border bg-card">
{sidebarCollapsed ? (
<div className="sidebar-drag flex items-center justify-center px-2 pb-3 pt-[38px]">
<div className={`sidebar-drag flex items-center justify-center px-2 pb-3 ${isMac ? "pt-[38px]" : "pt-3"}`}>
<button
aria-label="Open command palette"
title="Command palette"
@@ -740,7 +755,7 @@ export function App() {
</button>
</div>
) : (
<div className="sidebar-drag flex items-center gap-2.5 px-4 pb-4 pt-[38px]">
<div className={`sidebar-drag flex items-center gap-2.5 px-4 pb-4 ${isMac ? "pt-[38px]" : "pt-3"}`}>
<div className="relative min-w-0 flex-1">
<button
type="button"
@@ -996,7 +1011,7 @@ export function App() {
{section === "agents" ? (
<>
<OrgChart agents={companyAgents} onSelectAgent={(id) => setSelectedAgentId(id)} />
<OrgChart agents={companyAgents} connectors={snapshot.connectors} onSelectAgent={(id) => setSelectedAgentId(id)} />
<Panel title="Agent roster" action={<ActionButton label="New hire request" icon={UserPlus} tone="accent" onClick={() => setSelectedAgentId("__new__")} />}>
<AgentRoster
agents={companyAgents}
@@ -1037,7 +1052,7 @@ export function App() {
) : null}
{section === "orgchart" ? (
<OrgChart agents={companyAgents} onSelectAgent={(id) => { setSelectedAgentId(id); setSection("agents"); }} />
<OrgChart agents={companyAgents} connectors={snapshot.connectors} onSelectAgent={(id) => { setSelectedAgentId(id); setSection("agents"); }} />
) : null}
{section === "hiring" && currentCompany ? (
@@ -1445,11 +1460,11 @@ export function App() {
{section === "settings" && currentCompany ? (
<div className="space-y-10">
<Panel title="Profile and release">
<Panel title={locale === "zh" ? "配置" : "Profile and release"}>
<SettingsPane snapshot={snapshot} secrets={companySecrets} companyId={currentCompany!.id} onSaved={refresh} />
</Panel>
<Panel title="Secret bindings">
<SecretCreator companyId={currentCompany!.id} onSaved={refresh} />
<Panel title={locale === "zh" ? "模型管理" : "Model Management"}>
<ModelManagement connectors={snapshot.connectors} locale={locale} onSaved={refresh} />
</Panel>
</div>
) : null}
@@ -1479,10 +1494,12 @@ export function App() {
compact
value={selectedRun?.id ?? ""}
onChange={setSelectedRunId}
options={runs.length > 0
? runs.map((run) => ({ value: run.id, label: run.summary || `Run ${run.id.slice(0, 8)}` }))
: [{ value: "", label: t("console.noRuns") }]
}
options={(() => {
const activeRuns = runs.filter((r) => r.summary !== "Application shut down." && r.summary !== "Worker process crashed.");
return activeRuns.length > 0
? activeRuns.map((run) => ({ value: run.id, label: run.summary || `Run ${run.id.slice(0, 8)}` }))
: [{ value: "", label: t("console.noRuns") }];
})()}
/>
<button
type="button"
@@ -1525,7 +1542,7 @@ export function App() {
)}
</div>
<div className="h-[200px] overflow-auto px-5 py-3">
<pre className="font-mono whitespace-pre-wrap text-[12px] leading-relaxed text-muted-foreground">
<pre className="font-mono whitespace-pre-wrap break-all text-[12px] leading-relaxed text-muted-foreground">
{selectedRun ? logBufferRef.current[selectedRun.id] ?? t("common.waitingForLog") : t("common.noRunSelected")}
</pre>
</div>
@@ -1762,6 +1779,7 @@ export function App() {
<ToastContainer />
</div>
</div>
</TranslationContext.Provider>
</LocaleContext.Provider>
);
@@ -11,7 +11,11 @@ interface CompanyStepProps {
setCompanyDescription: (v: string) => void;
selectedConnectorId: string;
setSelectedConnectorId: (v: string) => void;
selectedModel: string;
setSelectedModel: (v: string) => void;
executionReadyConnectors: ProfileSnapshot["connectors"];
modelOptions: Array<{ id: string; label: string }>;
loadingModels: boolean;
workspacePath: string;
setWorkspacePath: (v: string) => void;
autoApproveHires: boolean;
@@ -22,8 +26,6 @@ interface CompanyStepProps {
onContinue: () => void;
onBack: () => void;
error: string | null;
isFirstLaunch: boolean;
selectedConnector: ProfileSnapshot["connectors"][number] | null;
currentLocale: AppLocale;
t: (key: string) => string;
}
@@ -33,9 +35,13 @@ export function CompanyStep({
setCompanyName,
companyDescription,
setCompanyDescription,
selectedConnectorId: _selectedConnectorId,
selectedConnectorId,
setSelectedConnectorId,
selectedModel,
setSelectedModel,
executionReadyConnectors,
modelOptions,
loadingModels,
workspacePath,
setWorkspacePath,
autoApproveHires,
@@ -46,8 +52,6 @@ export function CompanyStep({
onContinue,
onBack,
error,
isFirstLaunch,
selectedConnector,
currentLocale,
t,
}: CompanyStepProps) {
@@ -61,7 +65,7 @@ export function CompanyStep({
className="bg-card border border-border shadow-sm w-full rounded-lg p-7"
>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{t("onboarding.stepLabel1")} 1 / 3
{t("onboarding.stepLabel1")} 3 / 5
</div>
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
{t("onboarding.step1Title")}
@@ -82,18 +86,35 @@ export function CompanyStep({
placeholder={t("onboarding.companyDescriptionPlaceholder")}
rows={3}
/>
<Select
label={t("onboarding.primaryConnector")}
value={selectedConnector?.id ?? ""}
onChange={setSelectedConnectorId}
options={executionReadyConnectors.map((connector) => ({
value: connector.id,
label: connector.label,
}))}
/>
<div className="rounded-lg border border-border bg-muted px-4 py-3 text-[12px] leading-relaxed text-muted-foreground">
{t("onboarding.connectorInfo")}
{/* CEO Provider + Model */}
<div>
<div className="mb-2 text-[12px] font-medium text-muted-foreground">
{currentLocale === "zh" ? "CEO 运行时" : "CEO Runtime"}
</div>
<div className="grid grid-cols-2 gap-3">
<Select
label={currentLocale === "zh" ? "提供商" : "Provider"}
value={selectedConnectorId}
onChange={setSelectedConnectorId}
options={executionReadyConnectors.map((c) => ({
value: c.id,
label: c.id.startsWith("claude_compat") && c.notes ? `${c.notes}` : c.label,
}))}
/>
<Select
label={currentLocale === "zh" ? "模型" : "Model"}
value={selectedModel}
onChange={setSelectedModel}
disabled={loadingModels}
options={[
{ value: "", label: loadingModels ? (currentLocale === "zh" ? "加载中..." : "Loading...") : (currentLocale === "zh" ? "默认" : "Default") },
...modelOptions.map((m) => ({ value: m.id, label: m.label })),
]}
/>
</div>
</div>
<div>
<label
htmlFor="onboarding-workspace-path"
@@ -147,21 +168,19 @@ export function CompanyStep({
) : null}
<div className="mt-8 flex items-center justify-between">
{isFirstLaunch ? (
<button
type="button"
onClick={onBack}
className="text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
>
{t("onboarding.back")}
</button>
) : <div />}
<button
type="button"
onClick={onBack}
className="text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
>
{t("onboarding.back")}
</button>
<ActionButton
label={t("onboarding.continue")}
onClick={onContinue}
icon={Buildings}
tone="accent"
disabled={!companyName.trim() || !selectedConnector}
disabled={!companyName.trim() || !selectedConnectorId}
/>
</div>
</motion.div>
@@ -35,7 +35,7 @@ export function MissionStep({
className="bg-card border border-border shadow-sm w-full rounded-lg p-7"
>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{t("onboarding.stepLabel2")} 2 / 3
{t("onboarding.stepLabel2")} 4 / 5
</div>
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
{t("onboarding.step2Title")}
@@ -0,0 +1,146 @@
import { motion } from "framer-motion";
import {
CheckCircle,
XCircle,
ArrowRight,
ArrowCounterClockwise,
Terminal,
} from "@phosphor-icons/react";
import { ActionButton } from "../ui";
interface NodeCheckStepProps {
nodeStatus: { installed: boolean; version: string | null } | null;
checking: boolean;
onCheck: () => void;
onInstall: (command: string) => void;
onContinue: () => void;
onBack: () => void;
t: (key: string) => string;
}
const INSTALL_COMMANDS: Record<string, { label: string; code: string; command: string }> = {
darwin: {
label: "macOS",
code: "brew install node@22",
command: "brew install node@22",
},
win32: {
label: "Windows",
code: "winget install -e --id OpenJS.NodeJS.LTS",
command: "winget install -e --id OpenJS.NodeJS.LTS",
},
linux: {
label: "Linux",
code: `# Download and install nvm:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
# in lieu of restarting the shell
\\. "$HOME/.nvm/nvm.sh"
# Download and install Node.js:
nvm install 22
# Verify the Node.js version:
node -v`,
command: `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash && \\. "$HOME/.nvm/nvm.sh" && nvm install 22`,
},
};
export function NodeCheckStep({
nodeStatus,
checking,
onCheck,
onInstall,
onContinue,
onBack,
t,
}: NodeCheckStepProps) {
const platform = window.agentCompany.platform;
const platformInfo = INSTALL_COMMANDS[platform] ?? INSTALL_COMMANDS.linux;
return (
<motion.div
key="nodecheck"
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 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{t("onboarding.stepLabel1")} 1 / 5 — {t("onboarding.nodeCheckStep")}
</div>
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
{t("onboarding.nodeCheckTitle")}
</div>
<div className="mb-6 h-px bg-[color:var(--border)]" />
{/* Status */}
<div className="mb-5 flex items-center gap-2.5 text-[13px]">
{checking ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-primary" />
) : nodeStatus?.installed ? (
<CheckCircle size={18} weight="fill" className="shrink-0 text-green-600 dark:text-green-400" />
) : nodeStatus ? (
<XCircle size={18} weight="fill" className="shrink-0 text-destructive" />
) : null}
{checking ? (
<span className="text-muted-foreground">{t("onboarding.providerChecking")}...</span>
) : nodeStatus?.installed ? (
<span className="font-medium text-green-600 dark:text-green-400">
{t("onboarding.nodeInstalled").replace("{version}", nodeStatus.version ?? "")}
</span>
) : nodeStatus ? (
<span className="font-medium text-destructive">
{t("onboarding.nodeNotInstalled")}
</span>
) : null}
</div>
{/* Platform-specific install instructions (shown when not installed) */}
{nodeStatus && !nodeStatus.installed && (
<div className="mb-5 space-y-3">
<div className="text-[12px] font-medium text-muted-foreground">
{platformInfo.label}
</div>
<pre className="bg-muted rounded-lg border border-border px-4 py-3 font-mono text-[12px] leading-relaxed text-foreground whitespace-pre-wrap">
{platformInfo.code}
</pre>
<ActionButton
label={t("onboarding.installNodeTerminal")}
onClick={() => onInstall(platformInfo.command)}
icon={Terminal}
/>
</div>
)}
{/* Check Again */}
<div className="mb-6">
<ActionButton
label={t("onboarding.checkAgain")}
onClick={onCheck}
icon={ArrowCounterClockwise}
/>
</div>
{/* Bottom bar */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
>
{t("onboarding.back")}
</button>
<ActionButton
label={t("onboarding.continue")}
onClick={onContinue}
icon={ArrowRight}
tone="accent"
disabled={!nodeStatus?.installed}
/>
</div>
</motion.div>
);
}
@@ -2,40 +2,39 @@ import { motion } from "framer-motion";
import {
Buildings,
CheckCircle,
HardDrive,
Plugs,
Rocket,
Target,
} from "@phosphor-icons/react";
import type { ProfileSnapshot } from "@shared/types";
import type { AppLocale } from "@shared/locale";
import { ConnectorReadiness } from "./ConnectorReadiness";
import { AsciiAnimation } from "./AsciiAnimation";
type Step = "language" | "company" | "mission" | "launch";
export type OnboardingStep = "welcome" | "nodecheck" | "providers" | "company" | "mission" | "launch";
const STEP_LABELS_CONFIG = [
{ key: "onboarding.stepLabel1", icon: Buildings },
{ key: "onboarding.stepLabel2", icon: Target },
{ key: "onboarding.stepLabel3", icon: Rocket },
{ key: "onboarding.stepLabelNodeCheck", icon: HardDrive, step: "nodecheck" as const },
{ key: "onboarding.stepLabelProviders", icon: Plugs, step: "providers" as const },
{ key: "onboarding.stepLabelCompany", icon: Buildings, step: "company" as const },
{ key: "onboarding.stepLabelMission", icon: Target, step: "mission" as const },
{ key: "onboarding.stepLabelLaunch", icon: Rocket, step: "launch" as const },
] as const;
const STEP_ORDER: OnboardingStep[] = ["nodecheck", "providers", "company", "mission", "launch"];
interface OnboardingHeroProps {
step: Step;
connectors: ProfileSnapshot["connectors"];
step: OnboardingStep;
currentLocale: AppLocale;
hasRunnableConnector: boolean;
onOpenClaudeSettings: () => void;
t: (key: string) => string;
}
export function OnboardingHero({
step,
connectors,
currentLocale,
hasRunnableConnector,
onOpenClaudeSettings,
currentLocale: _currentLocale,
t,
}: OnboardingHeroProps) {
const stepIndex = step === "language" ? -1 : step === "company" ? 0 : step === "mission" ? 1 : 2;
const stepIndex = STEP_ORDER.indexOf(step);
const stepLabels = STEP_LABELS_CONFIG.map((cfg) => ({
label: t(cfg.key),
icon: cfg.icon,
@@ -43,7 +42,7 @@ export function OnboardingHero({
return (
<div className="relative flex min-h-0 items-center justify-center overflow-hidden px-8 py-12 md:px-16">
{/* Ambient gradient - subtle animated accent wash */}
{/* Ambient gradient */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.035]"
style={{
@@ -58,7 +57,6 @@ export function OnboardingHero({
}}
/>
{/* ASCII agent activity backdrop */}
<AsciiAnimation />
<motion.div
@@ -108,43 +106,32 @@ export function OnboardingHero({
</motion.p>
{/* Step progress */}
{step !== "language" ? (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="mt-10 flex items-center gap-3"
>
{stepLabels.map((entry, index) => (
<div key={entry.label} className="flex items-center gap-2">
{index > 0 ? (
<div className={`h-px w-6 transition-colors duration-300 ${index <= stepIndex ? "bg-primary" : "bg-[color:var(--border)]"}`} />
) : null}
<div
className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-[11px] font-medium transition-colors duration-300 ${
index < stepIndex
? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400"
: index === stepIndex
? "bg-accent text-primary"
: "bg-muted text-muted-foreground"
}`}
>
{index < stepIndex ? <CheckCircle size={14} weight="fill" /> : <entry.icon size={14} />}
{entry.label}
</div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="mt-10 flex flex-wrap items-center gap-2"
>
{stepLabels.map((entry, index) => (
<div key={entry.label} className="flex items-center gap-1.5">
{index > 0 ? (
<div className={`h-px w-4 transition-colors duration-300 ${index <= stepIndex ? "bg-primary" : "bg-[color:var(--border)]"}`} />
) : null}
<div
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors duration-300 ${
index < stepIndex
? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400"
: index === stepIndex
? "bg-accent text-primary"
: "bg-muted text-muted-foreground"
}`}
>
{index < stepIndex ? <CheckCircle size={12} weight="fill" /> : <entry.icon size={12} />}
{entry.label}
</div>
))}
</motion.div>
) : null}
{/* Connector readiness */}
<ConnectorReadiness
connectors={connectors}
currentLocale={currentLocale}
hasRunnableConnector={hasRunnableConnector}
onOpenClaudeSettings={onOpenClaudeSettings}
t={t}
/>
</div>
))}
</motion.div>
{/* ELYDORA signature */}
<motion.div
@@ -173,7 +160,6 @@ export function OnboardingHero({
</motion.div>
</motion.div>
{/* Inline keyframe for ambient drift */}
<style>{`
@keyframes hero-drift {
0% { transform: translate(0, 0) scale(1); }
@@ -1,18 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AnimatePresence } from "framer-motion";
import { isConnectorExecutionReady } from "@shared/connector-policy";
import type { OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types";
import type { ConnectorId, OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types";
import type { AppLocale } from "@shared/locale";
import { useT } from "../../i18n";
import { unwrap } from "../../lib/desktop";
import { OnboardingHero } from "./OnboardingHero";
import { LanguageStep } from "./LanguageStep";
import { OnboardingHero, type OnboardingStep } from "./OnboardingHero";
import { WelcomeStep } from "./WelcomeStep";
import { NodeCheckStep } from "./NodeCheckStep";
import { ProvidersStep } from "./ProvidersStep";
import { CompanyStep } from "./CompanyStep";
import { MissionStep } from "./MissionStep";
import { LaunchStep } from "./LaunchStep";
import { ClaudeSettingsDialog } from "./ClaudeSettingsDialog";
type Step = "language" | "company" | "mission" | "launch";
export function CompanyOnboarding({
connectors,
@@ -32,7 +31,178 @@ export function CompanyOnboarding({
isFirstLaunch?: boolean;
}) {
const t = useT();
const [step, setStep] = useState<Step>(isFirstLaunch ? "language" : "company");
/* ------------------------------------------------------------------ */
/* Step navigation */
/* ------------------------------------------------------------------ */
const [step, setStep] = useState<OnboardingStep>("welcome");
/* ------------------------------------------------------------------ */
/* Node.js check state */
/* ------------------------------------------------------------------ */
const [nodeStatus, setNodeStatus] = useState<{ installed: boolean; version: string | null } | null>(null);
const [nodeChecking, setNodeChecking] = useState(false);
const checkNode = useCallback(async () => {
setNodeChecking(true);
try {
const result = unwrap(await window.agentCompany.checkNodeInstalled());
setNodeStatus(result);
} catch {
setNodeStatus({ installed: false, version: null });
} finally {
setNodeChecking(false);
}
}, []);
// Auto-check node when entering the nodecheck step
useEffect(() => {
if (step === "nodecheck" && nodeStatus === null) {
void checkNode();
}
}, [step, nodeStatus, checkNode]);
/* ------------------------------------------------------------------ */
/* Provider / connector state */
/* ------------------------------------------------------------------ */
const executionReadyConnectors = useMemo(
() => connectors.filter((c) => isConnectorExecutionReady(c)),
[connectors],
);
const hasAtLeastOneReady = executionReadyConnectors.length > 0;
const [selectedConnectorId, setSelectedConnectorId] = useState<string>(
executionReadyConnectors[0]?.id ?? "",
);
// Keep selected connector in sync with readiness changes
useEffect(() => {
if (!selectedConnectorId && executionReadyConnectors[0]) {
setSelectedConnectorId(executionReadyConnectors[0].id);
return;
}
if (selectedConnectorId && !executionReadyConnectors.some((c) => c.id === selectedConnectorId)) {
setSelectedConnectorId(executionReadyConnectors[0]?.id ?? "");
}
}, [executionReadyConnectors, selectedConnectorId]);
const selectedConnector =
executionReadyConnectors.find((c) => c.id === selectedConnectorId) ??
executionReadyConnectors[0] ?? null;
/* ------------------------------------------------------------------ */
/* CEO model selection */
/* ------------------------------------------------------------------ */
const [selectedModel, setSelectedModel] = useState("");
const [modelOptions, setModelOptions] = useState<Array<{ id: string; label: string }>>([]);
const [loadingModels, setLoadingModels] = useState(false);
// Load models when the selected connector changes
useEffect(() => {
if (!selectedConnectorId) { setModelOptions([]); return; }
// For claude_compatible, parse user-configured models (comma-separated in connector.model)
if (selectedConnectorId.startsWith("claude_compat")) {
const compat = connectors.find((c) => c.id === selectedConnectorId);
const raw = compat?.model ?? "";
const models = raw.split(",").map((m) => m.trim()).filter(Boolean).map((m) => ({ id: m, label: m }));
setModelOptions(models);
if (models.length > 0 && !models.some((m) => m.id === selectedModel)) {
setSelectedModel(models[0].id);
}
setLoadingModels(false);
return;
}
let cancelled = false;
setLoadingModels(true);
window.agentCompany.listConnectorModels(selectedConnectorId)
.then((result) => {
if (cancelled) return;
if (result.ok) setModelOptions(result.data);
else setModelOptions([]);
})
.catch(() => { if (!cancelled) setModelOptions([]); })
.finally(() => { if (!cancelled) setLoadingModels(false); });
return () => { cancelled = true; };
}, [selectedConnectorId]);
/* ------------------------------------------------------------------ */
/* Provider callbacks (for ProvidersStep) */
/* ------------------------------------------------------------------ */
const handleTestConnector = useCallback(async (connectorId: string) => {
try {
unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId));
} catch {
// Health check result is stored in DB and reflected via domain-changed
}
}, []);
const handleSaveConnector = useCallback(async (connectorId: string, envBindingText: string, model: string | null, notes?: string) => {
const connector = connectors.find((c) => c.id === connectorId);
if (!connector) return;
try {
unwrap(await window.agentCompany.saveConnector({
id: connectorId as ConnectorId,
command: connector.command,
model,
envBindingText,
notes: notes ?? connector.notes,
}));
// Run health check AFTER save completes so the probe uses the new config
unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId));
} catch {
// Error is non-fatal; result reflected via domain-changed
}
}, [connectors]);
const handleLoginConnector = useCallback(async (connectorId: string) => {
try {
unwrap(await window.agentCompany.openConnectorAuthTerminal(connectorId as ConnectorId));
} catch {
// Terminal open failure is non-fatal
return;
}
// Poll connector health after login terminal opens (same pattern as ConnectorEditor)
let attempts = 0;
const poll = setInterval(async () => {
attempts++;
if (attempts > 12) { clearInterval(poll); return; }
try {
unwrap(await window.agentCompany.testConnector(connectorId as ConnectorId));
} catch { /* health check result reflected via domain-changed */ }
}, 5000);
}, []);
const handleInstallCommand = useCallback(async (title: string, command: string) => {
try {
unwrap(await window.agentCompany.openTerminalCommand({ title, command }));
} catch {
// Terminal open failure is non-fatal
return;
}
// Poll all connectors after install (install may make a new CLI available)
let attempts = 0;
const poll = setInterval(async () => {
attempts++;
if (attempts > 12) { clearInterval(poll); return; }
for (const c of connectors) {
if (c.status !== "ready") {
try { unwrap(await window.agentCompany.testConnector(c.id as ConnectorId)); } catch { /* reflected via domain-changed */ }
}
}
}, 5000);
}, [connectors]);
/* ------------------------------------------------------------------ */
/* Company / mission state */
/* ------------------------------------------------------------------ */
const [companyName, setCompanyName] = useState("");
const [companyDescription, setCompanyDescription] = useState("");
const [workspacePath, setWorkspacePath] = useState("");
@@ -43,68 +213,6 @@ export function CompanyOnboarding({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [launchSummary, setLaunchSummary] = useState<OnboardingBootstrapResult | null>(null);
const [claudeSettingsOpen, setClaudeSettingsOpen] = useState(false);
const [claudeApiKey, setClaudeApiKey] = useState("");
const [claudeBaseUrl, setClaudeBaseUrl] = useState("");
const [claudeModel, setClaudeModel] = useState("");
const [claudeSettingsSaving, setClaudeSettingsSaving] = useState(false);
const executionReadyConnectors = useMemo(
() => connectors.filter((connector) => isConnectorExecutionReady(connector)),
[connectors],
);
const [selectedConnectorId, setSelectedConnectorId] = useState<string>(executionReadyConnectors[0]?.id ?? "");
const hasRunnableConnector = executionReadyConnectors.length > 0;
const selectedConnector =
executionReadyConnectors.find((connector) => connector.id === selectedConnectorId) ?? executionReadyConnectors[0] ?? null;
useEffect(() => {
if (!selectedConnectorId && executionReadyConnectors[0]) {
setSelectedConnectorId(executionReadyConnectors[0].id);
return;
}
if (selectedConnectorId && !executionReadyConnectors.some((connector) => connector.id === selectedConnectorId)) {
setSelectedConnectorId(executionReadyConnectors[0]?.id ?? "");
}
}, [executionReadyConnectors, selectedConnectorId]);
const openClaudeSettings = useCallback(() => {
const claude = connectors.find((c) => c.id === "claude_local");
if (!claude) return;
const lines = claude.envBindingText.split(/\r?\n/);
setClaudeApiKey(lines.find((l) => l.startsWith("ANTHROPIC_API_KEY="))?.split("=").slice(1).join("=") ?? "");
setClaudeBaseUrl(lines.find((l) => l.startsWith("ANTHROPIC_BASE_URL="))?.split("=").slice(1).join("=") ?? "");
setClaudeModel(claude.model ?? "");
setClaudeSettingsOpen(true);
}, [connectors]);
const saveClaudeSettings = useCallback(async () => {
setClaudeSettingsSaving(true);
try {
const envLines: string[] = [];
if (claudeApiKey.trim()) envLines.push(`ANTHROPIC_API_KEY=${claudeApiKey.trim()}`);
if (claudeBaseUrl.trim()) envLines.push(`ANTHROPIC_BASE_URL=${claudeBaseUrl.trim()}`);
const claude = connectors.find((c) => c.id === "claude_local");
if (!claude) return;
const existingLines = claude.envBindingText.split(/\r?\n/).filter((l) => {
const key = l.split("=")[0]?.trim();
return key && key !== "ANTHROPIC_API_KEY" && key !== "ANTHROPIC_BASE_URL" && l.trim();
});
const newEnvText = [...envLines, ...existingLines].join("\n");
unwrap(await window.agentCompany.saveConnector({
id: "claude_local",
command: claude.command,
model: claudeModel.trim() || null,
envBindingText: newEnvText,
notes: claude.notes,
}));
setClaudeSettingsOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setClaudeSettingsSaving(false);
}
}, [claudeApiKey, claudeBaseUrl, claudeModel, connectors]);
const pickDirectory = useCallback(async () => {
setError(null);
@@ -128,6 +236,7 @@ export function CompanyOnboarding({
goalTitle: goalTitle.trim(),
goalDescription: goalDescription.trim(),
connectorId: selectedConnector?.id,
connectorModel: selectedModel.trim(),
}));
setLaunchSummary(result);
setStep("launch");
@@ -139,22 +248,19 @@ export function CompanyOnboarding({
goalDescription,
goalTitle,
selectedConnector?.id,
selectedModel,
workspacePath,
]);
const continueToMission = useCallback(async () => {
const continueToMission = useCallback(() => {
if (loading) return;
if (!companyName.trim()) {
setError(t("onboarding.enterCompanyName"));
return;
}
if (!selectedConnector) {
setError(t("onboarding.connectorRequired"));
return;
}
setError(null);
setStep("mission");
}, [companyName, loading, selectedConnector, t]);
}, [companyName, loading, t]);
const launchCompany = useCallback(async () => {
if (loading) return;
@@ -186,109 +292,136 @@ export function CompanyOnboarding({
}
}, [launchSummary, onDone, t]);
/* ------------------------------------------------------------------ */
/* Render */
/* ------------------------------------------------------------------ */
const isWelcome = step === "welcome";
const heroStep: OnboardingStep = step as OnboardingStep;
return (
<div className="relative grid h-[100dvh] overflow-hidden bg-background md:grid-cols-[minmax(0,1fr)_540px]">
{/* Drag region for macOS frameless title bar */}
<div
className={`relative h-[100dvh] overflow-hidden bg-background ${
isWelcome
? "flex items-stretch"
: "grid md:grid-cols-[minmax(0,1fr)_540px]"
}`}
>
{/* Drag region for frameless title bar */}
<div className="sidebar-drag absolute inset-x-0 top-0 z-10 h-[38px]" />
<OnboardingHero
step={step}
connectors={connectors}
currentLocale={currentLocale}
hasRunnableConnector={hasRunnableConnector}
onOpenClaudeSettings={openClaudeSettings}
t={t}
/>
<div className="min-h-0 overflow-y-auto border-l border-border bg-background">
<div className="relative flex min-h-full items-center px-6 py-10 md:px-10">
{canCancel && onCancel && !isFirstLaunch ? (
<button
type="button"
onClick={onCancel}
className="absolute right-6 top-6 text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
>
{t("onboarding.cancel")}
</button>
) : null}
<AnimatePresence mode="wait">
{step === "language" ? (
<LanguageStep
currentLocale={currentLocale}
onLocaleChange={onLocaleChange}
onContinue={() => setStep("company")}
t={t}
/>
) : null}
{step === "company" ? (
<CompanyStep
companyName={companyName}
setCompanyName={setCompanyName}
companyDescription={companyDescription}
setCompanyDescription={setCompanyDescription}
selectedConnectorId={selectedConnectorId}
setSelectedConnectorId={setSelectedConnectorId}
executionReadyConnectors={executionReadyConnectors}
workspacePath={workspacePath}
setWorkspacePath={setWorkspacePath}
autoApproveHires={autoApproveHires}
setAutoApproveHires={setAutoApproveHires}
reviewDeliverables={reviewDeliverables}
setReviewDeliverables={setReviewDeliverables}
onPickDirectory={() => void pickDirectory()}
onContinue={() => void continueToMission()}
onBack={() => setStep("language")}
error={error}
isFirstLaunch={isFirstLaunch}
selectedConnector={selectedConnector}
currentLocale={currentLocale}
t={t}
/>
) : null}
{step === "mission" ? (
<MissionStep
goalTitle={goalTitle}
setGoalTitle={setGoalTitle}
goalDescription={goalDescription}
setGoalDescription={setGoalDescription}
onLaunch={() => void launchCompany()}
onBack={() => setStep("company")}
loading={loading}
error={error}
t={t}
/>
) : null}
{step === "launch" && launchSummary ? (
<LaunchStep
launchSummary={launchSummary}
selectedConnector={selectedConnector}
onFinish={() => void finishLaunch()}
loading={loading}
error={error}
t={t}
/>
) : null}
</AnimatePresence>
</div>
</div>
{claudeSettingsOpen ? (
<ClaudeSettingsDialog
claudeApiKey={claudeApiKey}
setClaudeApiKey={setClaudeApiKey}
claudeBaseUrl={claudeBaseUrl}
setClaudeBaseUrl={setClaudeBaseUrl}
claudeModel={claudeModel}
setClaudeModel={setClaudeModel}
onSave={() => void saveClaudeSettings()}
onClose={() => setClaudeSettingsOpen(false)}
saving={claudeSettingsSaving}
{isWelcome ? (
<WelcomeStep
onContinue={() => setStep("nodecheck")}
currentLocale={currentLocale}
onLocaleChange={onLocaleChange}
t={t}
/>
) : null}
) : (
<>
<OnboardingHero
step={heroStep}
currentLocale={currentLocale}
t={t}
/>
<div className="min-h-0 overflow-y-auto border-l border-border bg-background">
<div className="relative flex min-h-full items-center px-6 py-10 md:px-10">
{canCancel && onCancel && !isFirstLaunch ? (
<button
type="button"
onClick={onCancel}
className="absolute right-6 top-6 text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
>
{t("onboarding.cancel")}
</button>
) : null}
<AnimatePresence mode="wait">
{step === "nodecheck" ? (
<NodeCheckStep
nodeStatus={nodeStatus}
checking={nodeChecking}
onCheck={() => void checkNode()}
onInstall={(command) => void handleInstallCommand("Install Node.js", command)}
onContinue={() => { setError(null); setStep("providers"); }}
onBack={() => setStep("welcome")}
t={t}
/>
) : null}
{step === "providers" ? (
<ProvidersStep
connectors={connectors}
onTestConnector={(id) => void handleTestConnector(id)}
onSaveConnector={(id, env, model, notes) => void handleSaveConnector(id, env, model, notes)}
onLoginConnector={(id) => void handleLoginConnector(id)}
onInstallCommand={(title, cmd) => void handleInstallCommand(title, cmd)}
hasAtLeastOneReady={hasAtLeastOneReady}
onContinue={() => { setError(null); setStep("company"); }}
onBack={() => setStep("nodecheck")}
error={error}
t={t}
/>
) : null}
{step === "company" ? (
<CompanyStep
companyName={companyName}
setCompanyName={setCompanyName}
companyDescription={companyDescription}
setCompanyDescription={setCompanyDescription}
selectedConnectorId={selectedConnectorId}
setSelectedConnectorId={setSelectedConnectorId}
selectedModel={selectedModel}
setSelectedModel={setSelectedModel}
executionReadyConnectors={executionReadyConnectors}
modelOptions={modelOptions}
loadingModels={loadingModels}
workspacePath={workspacePath}
setWorkspacePath={setWorkspacePath}
autoApproveHires={autoApproveHires}
setAutoApproveHires={setAutoApproveHires}
reviewDeliverables={reviewDeliverables}
setReviewDeliverables={setReviewDeliverables}
onPickDirectory={() => void pickDirectory()}
onContinue={() => void continueToMission()}
onBack={() => setStep("providers")}
error={error}
currentLocale={currentLocale}
t={t}
/>
) : null}
{step === "mission" ? (
<MissionStep
goalTitle={goalTitle}
setGoalTitle={setGoalTitle}
goalDescription={goalDescription}
setGoalDescription={setGoalDescription}
onLaunch={() => void launchCompany()}
onBack={() => setStep("company")}
loading={loading}
error={error}
t={t}
/>
) : null}
{step === "launch" && launchSummary ? (
<LaunchStep
launchSummary={launchSummary}
selectedConnector={selectedConnector}
onFinish={() => void finishLaunch()}
loading={loading}
error={error}
t={t}
/>
) : null}
</AnimatePresence>
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,646 @@
import { useState } from "react";
import { motion } from "framer-motion";
import {
ArrowRight,
CaretDown,
CaretRight,
ArrowSquareOut,
FloppyDisk,
SignIn,
Terminal,
} from "@phosphor-icons/react";
import type { ProfileSnapshot } from "@shared/types";
import { ActionButton } from "../ui";
/* ------------------------------------------------------------------ */
/* Compatible-API provider presets */
/* ------------------------------------------------------------------ */
const COMPATIBLE_PROVIDERS = [
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "kimi", label: "Kimi", baseUrl: "https://api.kimi.com/coding/", docUrl: "https://www.kimi.com/code/docs/en/more/third-party-agents.html" },
{ id: "kimi_cn", label: "Kimi CN", baseUrl: "https://api.moonshot.cn/anthropic", docUrl: "https://platform.moonshot.cn/docs/guide/agent-support#macos-%E5%92%8C-linux-1" },
{ id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" },
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" },
{ id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" },
{ id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" },
{ id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" },
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" },
{ id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" },
{ id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" },
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" },
{ id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" },
{ id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" },
{ id: "custom", label: "Custom", baseUrl: "", docUrl: "" },
] as const;
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
interface ProvidersStepProps {
connectors: ProfileSnapshot["connectors"];
onTestConnector: (connectorId: string) => void;
onSaveConnector: (connectorId: string, envBindingText: string, model: string | null, notes?: string) => void;
onLoginConnector: (connectorId: string) => void;
onInstallCommand: (title: string, command: string) => void;
hasAtLeastOneReady: boolean;
onContinue: () => void;
onBack: () => void;
error: string | null;
t: (key: string) => string;
}
/* ------------------------------------------------------------------ */
/* Status badge */
/* ------------------------------------------------------------------ */
type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string) => string }) {
if (status === "ready") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:bg-green-900/40 dark:text-green-400">
<span className="h-1.5 w-1.5 rounded-full bg-green-600 dark:bg-green-400" />
{t("onboarding.providerReady")}
</span>
);
}
if (status === "not_installed") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
{t("onboarding.providerNotInstalled")}
</span>
);
}
if (status === "auth_required") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-yellow-100 px-2 py-0.5 text-[11px] font-medium text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400">
<span className="h-1.5 w-1.5 rounded-full bg-yellow-600 dark:bg-yellow-400" />
{t("onboarding.providerAuthRequired")}
</span>
);
}
if (status === "detected" || status === "checking" as string) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
{t("onboarding.providerChecking")}
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-orange-100 px-2 py-0.5 text-[11px] font-medium text-orange-700 dark:bg-orange-900/40 dark:text-orange-400">
<span className="h-1.5 w-1.5 rounded-full bg-orange-600 dark:bg-orange-400" />
{status.replaceAll("_", " ")}
</span>
);
}
/* ------------------------------------------------------------------ */
/* Shared input class */
/* ------------------------------------------------------------------ */
const INPUT_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
/* ------------------------------------------------------------------ */
/* Provider card wrapper */
/* ------------------------------------------------------------------ */
function ProviderCard({
title,
badge,
expanded,
onToggle,
children,
}: {
title: string;
badge: React.ReactNode;
expanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<button
type="button"
onClick={onToggle}
className="flex w-full cursor-pointer items-center justify-between px-4 py-3 transition hover:bg-muted/50"
>
<div className="flex items-center gap-2.5">
{expanded ? <CaretDown size={14} className="text-muted-foreground" /> : <CaretRight size={14} className="text-muted-foreground" />}
<span className="text-[13px] font-semibold text-foreground">{title}</span>
</div>
{badge}
</button>
{expanded && <div className="space-y-3 px-4 pb-4">{children}</div>}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Install snippet */
/* ------------------------------------------------------------------ */
function InstallBlock({
macLinuxCmd,
windowsCmd,
onInstall,
title,
t,
}: {
macLinuxCmd: string;
windowsCmd: string | null;
onInstall: (title: string, command: string) => void;
title: string;
t: (key: string) => string;
}) {
const platform = window.agentCompany?.platform ?? "linux";
const isWindows = platform === "win32";
const cmd = isWindows && windowsCmd ? windowsCmd : macLinuxCmd;
return (
<div className="space-y-2">
<div className="text-[12px] font-medium text-muted-foreground">{t("onboarding.providerInstall")}</div>
{!isWindows && (
<div className="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-[12px] text-foreground">
{macLinuxCmd}
</div>
)}
{isWindows && windowsCmd && (
<div className="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-[12px] text-foreground">
{windowsCmd}
</div>
)}
<button
type="button"
onClick={() => onInstall(title, cmd)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<Terminal size={14} />
{t("onboarding.providerInstall")}
</button>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Compatible provider card (one per slot) */
/* ------------------------------------------------------------------ */
function CompatProviderCard({
slotId,
connector,
claudeInstalled,
onSaveConnector,
onInstallCommand,
expanded,
onToggle,
t,
}: {
slotId: string;
connector: ProfileSnapshot["connectors"][number] | null;
claudeInstalled: boolean;
onSaveConnector: ProvidersStepProps["onSaveConnector"];
onInstallCommand: ProvidersStepProps["onInstallCommand"];
expanded: boolean;
onToggle: () => void;
t: (key: string) => string;
}) {
function parseEnv(text: string, key: string): string {
const line = text.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
return line ? line.slice(key.length + 1) : "";
}
const [selectedProvider, setSelectedProvider] = useState<string>(() => {
const saved = connector?.notes ?? "";
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
return match?.id ?? COMPATIBLE_PROVIDERS[0].id;
});
const [apiKey, setApiKey] = useState(() => parseEnv(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"));
const [baseUrl, setBaseUrl] = useState<string>(() =>
parseEnv(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") || COMPATIBLE_PROVIDERS[0].baseUrl,
);
const [models, setModels] = useState<string[]>(() => {
const raw = connector?.model ?? "";
return raw.split(",").map((m) => m.trim()).filter(Boolean);
});
const [newModel, setNewModel] = useState("");
const status = connector?.status ?? "not_installed";
const providerLabel = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? t("onboarding.claudeCompatibleLabel");
const displayTitle = connector?.notes || providerLabel;
function handleProviderChange(providerId: string) {
setSelectedProvider(providerId);
const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId);
if (provider) setBaseUrl(provider.baseUrl);
}
function handleSave() {
const lines: string[] = [];
if (apiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${apiKey.trim()}`);
if (baseUrl.trim()) lines.push(`ANTHROPIC_BASE_URL=${baseUrl.trim()}`);
const label = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? "";
const modelsCsv = models.join(",") || null;
onSaveConnector(slotId, lines.join("\n"), modelsCsv, label);
}
function addModel() {
const trimmed = newModel.trim();
if (!trimmed) return;
setModels((prev) => {
if (prev.includes(trimmed)) return prev;
return [...prev, trimmed];
});
setNewModel("");
}
const selectedProviderData = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider);
return (
<ProviderCard
title={displayTitle}
badge={<StatusBadge status={status} t={t} />}
expanded={expanded}
onToggle={onToggle}
>
{!claudeInstalled && (
<InstallBlock
macLinuxCmd="curl -fsSL https://claude.ai/install.sh | bash"
windowsCmd="irm https://claude.ai/install.ps1 | iex"
onInstall={onInstallCommand}
title="Install Claude Code"
t={t}
/>
)}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{t("onboarding.providerSelectProvider")}
</label>
<select
className={INPUT_CLS}
value={selectedProvider}
onChange={(e) => handleProviderChange(e.target.value)}
>
{COMPATIBLE_PROVIDERS.map((p) => (
<option key={p.id} value={p.id} className="bg-card text-foreground">{p.label}</option>
))}
</select>
</div>
{selectedProviderData?.docUrl ? (
<button
type="button"
onClick={() => window.open(selectedProviderData.docUrl, "_blank")}
className="inline-flex items-center gap-1 text-[12px] text-primary transition hover:underline"
>
<ArrowSquareOut size={12} />
{t("onboarding.providerViewDocs")}
</button>
) : null}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerApiKey")}</label>
<input type="password" className={INPUT_CLS} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-..." />
</div>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerBaseUrl")}</label>
<input className={INPUT_CLS} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" />
</div>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerModel")}</label>
{models.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{models.map((m) => (
<span key={m} className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-[12px] text-foreground">
{m}
<button type="button" onClick={() => setModels(models.filter((x) => x !== m))} className="ml-0.5 text-muted-foreground hover:text-destructive">×</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<input
className={INPUT_CLS}
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
placeholder="model-name"
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }}
/>
<button
type="button"
onClick={addModel}
disabled={!newModel.trim()}
className="shrink-0 rounded-lg border border-border bg-card px-3 py-2 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
>+</button>
</div>
</div>
<button
type="button"
onClick={handleSave}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<FloppyDisk size={14} />
{t("onboarding.providerSaveCheck")}
</button>
</ProviderCard>
);
}
/* ------------------------------------------------------------------ */
/* Main component */
/* ------------------------------------------------------------------ */
export function ProvidersStep({
connectors,
onTestConnector,
onSaveConnector,
onLoginConnector,
onInstallCommand,
hasAtLeastOneReady,
onContinue,
onBack,
error,
t,
}: ProvidersStepProps) {
// Expansion state
const [expandedCard, setExpandedCard] = useState<string | null>("claude");
// Connector lookups
const claudeConnector = connectors.find((c) => c.id === "claude_local");
const codexConnector = connectors.find((c) => c.id === "codex_local");
const geminiConnector = connectors.find((c) => c.id === "gemini_local");
const compatConnector = connectors.find((c) => c.id === "claude_compatible");
// Parse env bindings from connector data for form initialization
function parseEnvValue(envText: string, key: string): string {
const line = envText.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
return line ? line.slice(key.length + 1) : "";
}
// Claude Code (Official) form — init from saved connector data
const [claudeApiKey, setClaudeApiKey] = useState(() =>
parseEnvValue(claudeConnector?.envBindingText ?? "", "ANTHROPIC_API_KEY"),
);
// Codex form
const [codexApiKey, setCodexApiKey] = useState(() =>
parseEnvValue(codexConnector?.envBindingText ?? "", "OPENAI_API_KEY"),
);
// Compatible API slots
const COMPAT_SLOTS = ["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"] as const;
const compatSlots = COMPAT_SLOTS.map((id) => connectors.find((c) => c.id === id));
// A slot is "active" if it has been configured (has ANTHROPIC_BASE_URL)
const activeCompatSlots = COMPAT_SLOTS.filter((id) => {
const c = connectors.find((conn) => conn.id === id);
return c && c.envBindingText.includes("ANTHROPIC_BASE_URL");
});
const nextAvailableSlot = COMPAT_SLOTS.find((id) => !activeCompatSlots.includes(id)) ?? null;
// Always show active slots + the first empty slot for adding
const visibleCompatSlots = [...activeCompatSlots];
if (nextAvailableSlot && !visibleCompatSlots.includes(nextAvailableSlot)) {
visibleCompatSlots.push(nextAvailableSlot);
}
const claudeStatus = claudeConnector?.status ?? "not_installed";
const codexStatus = codexConnector?.status ?? "not_installed";
const geminiStatus = geminiConnector?.status ?? "not_installed";
const toggle = (card: string) => setExpandedCard((prev) => (prev === card ? null : card));
/* ------ save helpers ------ */
function handleClaudeSave() {
const lines: string[] = [];
if (claudeApiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${claudeApiKey.trim()}`);
onSaveConnector("claude_local", lines.join("\n"), null);
}
function handleCodexSave() {
const lines: string[] = [];
if (codexApiKey.trim()) lines.push(`OPENAI_API_KEY=${codexApiKey.trim()}`);
onSaveConnector("codex_local", lines.join("\n"), null);
}
function openExternalDoc(url: string) {
window.open(url, "_blank");
}
return (
<motion.div
key="providers"
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="w-full rounded-lg border border-border bg-card p-7 shadow-sm"
>
{/* Step label */}
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{t("onboarding.stepLabel1")} 2 / 5 — {t("onboarding.providersStep")}
</div>
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-foreground">
{t("onboarding.providersTitle")}
</div>
<div className="mb-4 text-[13px] leading-relaxed text-muted-foreground">
{t("onboarding.providersSubtitle")}
</div>
<div className="mb-6 h-px bg-[color:var(--border)]" />
{/* Provider cards */}
<div className="space-y-3">
{/* ---- Card 1: Claude Code (Official) ---- */}
<ProviderCard
title={t("onboarding.claudeCodeLabel")}
badge={<StatusBadge status={claudeStatus} t={t} />}
expanded={expandedCard === "claude"}
onToggle={() => toggle("claude")}
>
{claudeStatus === "not_installed" && (
<InstallBlock
macLinuxCmd="curl -fsSL https://claude.ai/install.sh | bash"
windowsCmd="irm https://claude.ai/install.ps1 | iex"
onInstall={onInstallCommand}
title="Install Claude Code"
t={t}
/>
)}
<button
type="button"
onClick={() => onLoginConnector("claude_local")}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<SignIn size={14} />
{t("onboarding.providerLogin")}
</button>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{t("onboarding.providerApiKey")} <span className="font-normal">(optional)</span>
</label>
<input
type="password"
className={INPUT_CLS}
value={claudeApiKey}
onChange={(e) => setClaudeApiKey(e.target.value)}
placeholder="sk-ant-..."
/>
</div>
<button
type="button"
onClick={handleClaudeSave}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<FloppyDisk size={14} />
{t("onboarding.providerSaveCheck")}
</button>
</ProviderCard>
{/* ---- Card 2: Codex (Official) ---- */}
<ProviderCard
title={t("onboarding.codexLabel")}
badge={<StatusBadge status={codexStatus} t={t} />}
expanded={expandedCard === "codex"}
onToggle={() => toggle("codex")}
>
{codexStatus === "not_installed" && (
<InstallBlock
macLinuxCmd="npm i -g @openai/codex"
windowsCmd={null}
onInstall={onInstallCommand}
title="Install Codex"
t={t}
/>
)}
<button
type="button"
onClick={() => onLoginConnector("codex_local")}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<SignIn size={14} />
{t("onboarding.providerLogin")}
</button>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{t("onboarding.providerApiKey")} (OPENAI_API_KEY)
</label>
<input
type="password"
className={INPUT_CLS}
value={codexApiKey}
onChange={(e) => setCodexApiKey(e.target.value)}
placeholder="sk-..."
/>
</div>
<button
type="button"
onClick={handleCodexSave}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<FloppyDisk size={14} />
{t("onboarding.providerSaveCheck")}
</button>
</ProviderCard>
{/* ---- Card 3: Gemini CLI (Official) ---- */}
<ProviderCard
title={t("onboarding.geminiLabel")}
badge={<StatusBadge status={geminiStatus} t={t} />}
expanded={expandedCard === "gemini"}
onToggle={() => toggle("gemini")}
>
{geminiStatus === "not_installed" && (
<InstallBlock
macLinuxCmd="npm install -g @google/gemini-cli"
windowsCmd={null}
onInstall={onInstallCommand}
title="Install Gemini CLI"
t={t}
/>
)}
<button
type="button"
onClick={() => onLoginConnector("gemini_local")}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<SignIn size={14} />
{t("onboarding.providerLogin")}
</button>
<button
type="button"
onClick={() => onTestConnector("gemini_local")}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<FloppyDisk size={14} />
{t("onboarding.providerCheck")}
</button>
</ProviderCard>
{/* ---- Compatible API providers (up to 3 slots) ---- */}
{visibleCompatSlots.map((slotId) => (
<CompatProviderCard
key={slotId}
slotId={slotId}
connector={compatSlots[COMPAT_SLOTS.indexOf(slotId)] ?? null}
claudeInstalled={claudeStatus !== "not_installed"}
onSaveConnector={onSaveConnector}
onInstallCommand={onInstallCommand}
expanded={expandedCard === slotId}
onToggle={() => toggle(slotId)}
t={t}
/>
))}
</div>
{/* Error message */}
{error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-red-100 px-4 py-3 text-[13px] text-destructive dark:bg-red-900/50">
{error}
</div>
) : null}
{/* Bottom bar */}
<div className="mt-8 flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
>
{t("onboarding.back")}
</button>
<div className="flex items-center gap-4">
{!hasAtLeastOneReady && (
<span className="text-[12px] text-muted-foreground">
{t("onboarding.providerAtLeastOne")}
</span>
)}
<ActionButton
label={t("onboarding.continue")}
onClick={onContinue}
icon={ArrowRight}
tone="accent"
disabled={!hasAtLeastOneReady}
/>
</div>
</div>
</motion.div>
);
}
@@ -0,0 +1,94 @@
import { motion } from "framer-motion";
import { GlobeSimple, Rocket } from "@phosphor-icons/react";
import type { AppLocale } from "@shared/locale";
import { APP_LOCALES, LOCALE_LABELS } from "@shared/locale";
import { AsciiAnimation } from "./AsciiAnimation";
import { ActionButton } from "../ui";
interface WelcomeStepProps {
onContinue: () => void;
currentLocale: AppLocale;
onLocaleChange: (locale: AppLocale) => void;
t: (key: string) => string;
}
export function WelcomeStep({ onContinue, currentLocale, onLocaleChange, t }: WelcomeStepProps) {
return (
<div className="relative flex w-full min-h-[100dvh] flex-col items-center justify-center overflow-hidden bg-background">
<AsciiAnimation />
<div
className="pointer-events-none absolute inset-x-0 top-0 z-10 h-40"
style={{ background: "linear-gradient(to bottom, var(--background), transparent)" }}
/>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-40"
style={{ background: "linear-gradient(to top, var(--background), transparent)" }}
/>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="relative z-20 flex flex-col items-center text-center px-8"
>
<h1 className="text-[48px] font-bold leading-[1.05] tracking-[-0.03em] text-foreground md:text-[64px]">
AgentCompany
</h1>
<p className="mt-4 max-w-[480px] text-[16px] leading-relaxed text-muted-foreground">
{t("onboarding.welcomeSubtitle")}
</p>
<div className="mt-10">
<ActionButton
label={t("onboarding.getStarted")}
icon={Rocket}
tone="accent"
onClick={onContinue}
/>
</div>
{/* Inline language selector */}
<div className="mt-6 flex items-center gap-2">
<GlobeSimple size={14} className="text-muted-foreground" />
{APP_LOCALES.map((locale, i) => (
<span key={locale} className="flex items-center gap-2">
{i > 0 && <span className="text-[12px] text-muted-foreground">/</span>}
<button
type="button"
onClick={() => onLocaleChange(locale)}
className={`text-[13px] transition ${
currentLocale === locale
? "font-semibold text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{LOCALE_LABELS[locale]}
</button>
</span>
))}
</div>
{/* ELYDORA signature */}
<div className="mt-12 flex items-center gap-3">
<div
className="h-px w-8"
style={{ background: "linear-gradient(90deg, var(--primary), transparent)" }}
/>
<span
className="select-none text-[10px] font-semibold uppercase tracking-[0.2em]"
style={{
background: "linear-gradient(135deg, var(--primary) 0%, var(--muted-foreground) 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
}}
>
by ELYDORA
</span>
</div>
</motion.div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useState, useEffect, useCallback } from "react";
import { Minus, Square, X } from "@phosphor-icons/react";
export default function TitleBar() {
const [maximized, setMaximized] = useState(false);
const refreshMaximized = useCallback(() => {
void window.agentCompany.windowIsMaximized().then(setMaximized);
}, []);
useEffect(() => {
refreshMaximized();
const onResize = () => refreshMaximized();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [refreshMaximized]);
return (
<div className="titlebar-drag flex h-[32px] shrink-0 select-none items-center justify-between bg-card border-b border-border">
<div className="flex items-center gap-0.5 pl-3">
<span className="text-[12px] font-semibold tracking-[-0.01em] text-muted-foreground">
AgentCompany
</span>
</div>
<div className="flex h-full">
<button
type="button"
aria-label="Minimize"
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={() => void window.agentCompany.windowMinimize()}
>
<Minus size={14} weight="bold" />
</button>
<button
type="button"
aria-label={maximized ? "Restore" : "Maximize"}
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={() => {
void window.agentCompany.windowMaximize();
setTimeout(refreshMaximized, 50);
}}
>
{maximized ? (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
<rect x="2" y="0" width="8" height="8" rx="1" />
<rect x="0" y="2" width="8" height="8" rx="1" fill="var(--card)" />
<rect x="0" y="2" width="8" height="8" rx="1" />
</svg>
) : (
<Square size={12} weight="bold" />
)}
</button>
<button
type="button"
aria-label="Close"
className="titlebar-button flex h-full w-[46px] items-center justify-center text-muted-foreground transition hover:bg-red-500 hover:text-white"
onClick={() => void window.agentCompany.windowClose()}
>
<X size={14} weight="bold" />
</button>
</div>
</div>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ export { CommunicationHub } from "./communication/CommunicationHub"
export { ChannelSidebar, FilterBar } from "./communication/ChannelSidebar"
export { MessageList } from "./communication/MessageList"
export { MessageBubble, TaskCommentBubble, AgentAvatar } from "./communication/MessageItem"
export { MessageComposer, TaskDiscussionComposer, ComposeModal, NoChannelComposer } from "./communication/MessageComposer"
export { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./communication/MessageComposer"
export { ChannelHeader } from "./communication/ChannelHeader"
export type { ChannelDescriptor, TaskDiscussionDescriptor, AnyChannel } from "./communication/ChannelHeader"
export type { FilterState } from "./communication/ChannelSidebar"
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { ArrowBendUpLeft, PaperPlaneTilt, X, ChatCircle } from "@phosphor-icons/react"
import type { AgentMessageRecord, AgentRecord, MessageChannel, MessagePriority, ProjectRecord } from "@shared/types"
import type { AgentMessageRecord, AgentRecord, MessageChannel } from "@shared/types"
import { unwrap } from "../../lib/desktop"
import { InlineNotice, Modal, Select, Input, TextArea } from "../ui"
import { InlineNotice } from "../ui"
import { AgentAvatar } from "./MessageItem"
import { useT } from "../../i18n"
@@ -98,44 +98,3 @@ export function NoChannelComposer() {
const t = useT()
return <div className="border-t border-border bg-card px-4 py-2.5"><div className="flex items-center gap-2 text-[11px] text-muted-foreground"><ChatCircle size={14} /><span>{t("comm.selectChannelSend")}</span></div></div>
}
export function ComposeModal({ open, onClose, agents, projects, companyId, onSent }: { open: boolean; onClose: () => void; agents: AgentRecord[]; projects?: ProjectRecord[]; companyId: string; onSent: () => void }) {
const t = useT()
const [fromAgentId, setFromAgentId] = useState(agents[0]?.id ?? ""); const [toAgentId, setToAgentId] = useState(""); const [channel, setChannel] = useState<MessageChannel>("company")
const [channelTargetId, setChannelTargetId] = useState(""); const [subject, setSubject] = useState(""); const [body, setBody] = useState("")
const [priority, setPriority] = useState<MessagePriority>("normal"); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState<string | null>(null)
const deptOpts = useMemo(() => { const d = new Set<string>(); for (const a of agents) { if (a.department) d.add(a.department) }; return Array.from(d).sort() }, [agents])
useEffect(() => { if (channel !== "direct") setToAgentId(""); if (channel !== "department" && channel !== "project") setChannelTargetId("") }, [channel])
useEffect(() => { if (agents.length === 0) { if (fromAgentId) setFromAgentId(""); return }; if (!agents.some((a) => a.id === fromAgentId)) setFromAgentId(agents[0]?.id ?? "") }, [agents, fromAgentId])
const handleSend = useCallback(async () => {
const reqTarget = channel === "department" || channel === "project"
if (!body.trim() || !fromAgentId || !subject.trim() || (channel === "direct" && !toAgentId) || (reqTarget && !channelTargetId)) return
if (!agents.some((a) => a.id === fromAgentId)) { setSendError("Select a valid sender."); return }
setSending(true); setSendError(null)
try { unwrap(await window.agentCompany.sendAgentMessage({ companyId, fromAgentId, toAgentId: channel === "direct" && toAgentId ? toAgentId : null, channel, channelTargetId: reqTarget ? channelTargetId || null : null, subject: subject.trim(), body: body.trim(), priority, parentMessageId: null, attachmentsJson: "[]" })); setSubject(""); setBody(""); setPriority("normal"); onSent(); onClose() } catch (e) { setSendError(e instanceof Error ? e.message : "Failed to send.") } finally { setSending(false) }
}, [agents, body, channel, channelTargetId, companyId, fromAgentId, onClose, onSent, priority, subject, toAgentId])
return (
<Modal open={open} title={t("comm.composeMessage")} onClose={onClose} width="560px">
<div className="space-y-4">
{sendError ? <InlineNotice message={sendError} /> : null}
<Select label={t("comm.fromAgent")} value={fromAgentId} onChange={setFromAgentId} options={agents.map((a) => ({ value: a.id, label: `${a.name} (${a.role})` }))} />
<Select label={t("comm.channel")} value={channel} onChange={(v) => setChannel(v as MessageChannel)} options={[{ value: "company", label: t("comm.companyBroadcast") }, { value: "department", label: t("comm.departmentChannel") }, { value: "project", label: t("comm.projectChannel") }, { value: "incident", label: t("comm.incidentChannel") }, { value: "direct", label: t("comm.directMessage") }]} />
{channel === "direct" ? <Select label="To Agent" value={toAgentId} onChange={setToAgentId} options={[{ value: "", label: "Select a recipient..." }, ...agents.filter((a) => a.id !== fromAgentId).map((a) => ({ value: a.id, label: `${a.name} (${a.role})` }))]} />
: channel === "department" ? <Select label="Department" value={channelTargetId} onChange={setChannelTargetId} options={[{ value: "", label: "Select department..." }, ...deptOpts.map((d) => ({ value: d, label: d.replace("_", " ").replace(/\b\w/g, (c) => c.toUpperCase()) }))]} />
: channel === "project" ? <Select label="Project" value={channelTargetId} onChange={setChannelTargetId} options={[{ value: "", label: "Select project..." }, ...(projects ?? []).map((p) => ({ value: p.id, label: p.name }))]} /> : null}
<Input label={t("comm.subject")} value={subject} onChange={setSubject} placeholder={t("comm.messageSubject")} />
<TextArea label={t("comm.message")} value={body} onChange={setBody} placeholder={t("comm.writeMessage")} rows={5} />
<Select label="Priority" value={priority} onChange={(v) => setPriority(v as MessagePriority)} options={[{ value: "low", label: "Low" }, { value: "normal", label: "Normal" }, { value: "urgent", label: "Urgent" }]} />
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className="rounded-lg px-3 py-2 text-[13px] font-medium text-muted-foreground transition hover:bg-muted">{t("common.cancel")}</button>
<button type="button" onClick={() => void handleSend()} disabled={!body.trim() || !subject.trim() || !fromAgentId || (channel === "direct" && !toAgentId) || ((channel === "department" || channel === "project") && !channelTargetId) || sending} className="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] font-semibold bg-primary text-primary-foreground transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none">
{sending ? <svg className="h-4 w-4 animate-spin" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.3" /><path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg> : <PaperPlaneTilt size={14} weight="fill" />}{t("comm.sendMessage")}
</button>
</div>
</div>
</Modal>
)
}
+3 -10
View File
@@ -43,7 +43,6 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
const authPollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const authActionCopy = getAuthActionCopy(connector);
const needsLogin = connector.authState === "required" || connector.status === "auth_required";
const requiresExplicitModel = false;
const shouldLoadModelOptions = connector.capabilityMatrix.modelDiscovery;
useEffect(() => {
@@ -87,14 +86,14 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
}, [connector.id, shouldLoadModelOptions]);
const modelSelectOptions = useMemo(() => {
const options = [{ value: "", label: requiresExplicitModel ? "Please select a model..." : "Use connector default" }];
const options = [{ value: "", label: "Use connector default" }];
const hasCurrentModel = modelOptions.some((option) => option.id === model);
if (model && !hasCurrentModel) {
options.push({ value: model, label: `${model} (saved)` });
}
options.push(...modelOptions.map((option) => ({ value: option.id, label: option.label || option.id })));
return options;
}, [model, modelOptions, requiresExplicitModel]);
}, [model, modelOptions]);
const runHealthCheck = useCallback(async (quiet = false) => {
setAutoChecking(true);
@@ -189,7 +188,7 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
{shouldLoadModelOptions ? (
<div className="grid gap-2">
<Select
label={requiresExplicitModel ? "Model (required)" : "Model"}
label="Model"
value={model}
onChange={setModel}
options={modelSelectOptions}
@@ -200,11 +199,6 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
{modelOptionsError ? (
<InlineNotice message={modelOptionsError} />
) : null}
{requiresExplicitModel && !model.trim() ? (
<div className="rounded-lg border border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-[12px] text-yellow-600 dark:text-yellow-400">
OpenCode is detected, but it still needs an explicit provider/model selection before AgentCompany can mark it ready.
</div>
) : null}
</div>
) : (
<Input label="Model" value={model} onChange={setModel} placeholder="Connector default" />
@@ -246,7 +240,6 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
})()}
icon={CheckCircle}
loading={saving}
disabled={requiresExplicitModel && !model.trim()}
/>
</div>
<div className="rounded-lg border border-border bg-muted px-4 py-4 text-[12px] leading-relaxed text-muted-foreground">
@@ -57,7 +57,10 @@ export function Dashboard(props: DashboardProps) {
const totalSpend = useMemo(() => costs.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0), [costs]);
// Attention items
const failedRuns = useMemo(() => runs.filter((r) => r.status === "failed" || r.status === "timed_out" || r.status === "interrupted"), [runs]);
const failedRuns = useMemo(() => runs.filter((r) =>
(r.status === "failed" || r.status === "timed_out") ||
(r.status === "interrupted" && r.summary !== "Application shut down." && r.summary !== "Worker process crashed.")
), [runs]);
const urgentMessages = useMemo(() => messages.filter((m) => !m.readAt && (m.priority === "urgent" || m.channel === "incident")), [messages]);
const hasAttentionItems = pendingApprovals.length > 0 || failedRuns.length > 0 || urgentMessages.length > 0;
@@ -1,127 +0,0 @@
import { useMemo } from "react";
import {
Buildings,
ChartLineUp,
Cpu,
CurrencyCircleDollar,
Heartbeat,
Lightning,
Rocket,
ShieldCheck,
Target,
TrendUp,
Users,
} from "@phosphor-icons/react";
import type { AgentRecord, RunRecord, SectionId, TaskRecord } from "@shared/types";
const DEPARTMENT_INFO: Record<string, { label: string; icon: React.ElementType; color: string }> = {
executive: { label: "Executive", icon: Buildings, color: "var(--primary)" },
engineering: { label: "Engineering", icon: Cpu, color: "#6366f1" },
product: { label: "Product", icon: Rocket, color: "#8b5cf6" },
design: { label: "Design", icon: Target, color: "#ec4899" },
marketing: { label: "Marketing", icon: TrendUp, color: "#0ea5e9" },
sales: { label: "Sales", icon: ChartLineUp, color: "#10b981" },
hr: { label: "People", icon: Users, color: "#06b6d4" },
finance: { label: "Finance", icon: CurrencyCircleDollar, color: "#14b8a6" },
legal: { label: "Legal", icon: ShieldCheck, color: "#64748b" },
operations: { label: "Operations", icon: Lightning, color: "#475569" },
customer_support: { label: "Support", icon: Heartbeat, color: "#3b82f6" },
research: { label: "Research", icon: Target, color: "#a855f7" },
};
export function DepartmentOverview({
agents,
tasks,
runs,
onNavigate,
}: {
agents: AgentRecord[];
tasks: TaskRecord[];
runs: RunRecord[];
onNavigate: (section: SectionId) => void;
}) {
const departments = useMemo(() => {
const depts = new Map<string, { agents: AgentRecord[]; tasks: TaskRecord[]; activeRuns: number }>();
for (const agent of agents) {
if (agent.status === "terminated") continue;
const dept = agent.department ?? "unassigned";
if (!depts.has(dept)) depts.set(dept, { agents: [], tasks: [], activeRuns: 0 });
depts.get(dept)!.agents.push(agent);
}
for (const task of tasks) {
if (task.assigneeAgentId) {
const agent = agents.find((a) => a.id === task.assigneeAgentId);
if (agent?.department) {
const dept = depts.get(agent.department);
if (dept) dept.tasks.push(task);
}
}
}
for (const run of runs) {
if (run.status === "running") {
const agent = agents.find((a) => a.id === run.agentId);
if (agent?.department) {
const dept = depts.get(agent.department);
if (dept) dept.activeRuns++;
}
}
}
return Array.from(depts.entries())
.filter(([key]) => key !== "unassigned")
.sort((a, b) => b[1].agents.length - a[1].agents.length);
}, [agents, tasks, runs]);
if (departments.length <= 1) return null;
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
{departments.map(([deptId, data]) => {
const info = DEPARTMENT_INFO[deptId] ?? { label: deptId, icon: Buildings, color: "var(--muted-foreground)" };
const Icon = info.icon;
const activeTasks = data.tasks.filter((t) => t.status === "in_progress" || t.status === "in_review").length;
const doneTasks = data.tasks.filter((t) => t.status === "done").length;
const blockedTasks = data.tasks.filter((t) => t.status === "blocked").length;
const runningAgents = data.agents.filter((a) => a.status === "running").length;
return (
<button
key={deptId}
type="button"
onClick={() => onNavigate("agents")}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 group relative overflow-hidden rounded-lg border border-border bg-card p-3.5 text-left transition duration-150 hover:border-border hover:shadow-sm"
>
<div className="absolute left-0 top-0 h-full w-[3px]" style={{ backgroundColor: info.color }} />
<div className="flex items-center gap-2 pl-1">
<Icon size={15} weight="fill" style={{ color: info.color }} />
<span className="truncate text-[11px] font-medium text-foreground">{info.label}</span>
{runningAgents > 0 && (
<span className="ml-auto flex items-center gap-1">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-green-600 dark:bg-green-400" />
<span className="text-[9px] text-green-600 dark:text-green-400">{runningAgents}</span>
</span>
)}
</div>
<div className="mt-2 grid grid-cols-3 gap-1 pl-1">
<div>
<div className="text-[16px] font-bold tabular-nums text-foreground">{data.agents.length}</div>
<div className="text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">People</div>
</div>
<div>
<div className="text-[16px] font-bold tabular-nums text-primary">{activeTasks}</div>
<div className="text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Active</div>
</div>
<div>
<div className={`text-[16px] font-bold tabular-nums ${blockedTasks > 0 ? "text-destructive" : "text-green-600 dark:text-green-400"}`}>
{blockedTasks > 0 ? blockedTasks : doneTasks}
</div>
<div className="text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{blockedTasks > 0 ? "Blocked" : "Done"}
</div>
</div>
</div>
</button>
);
})}
</div>
);
}
@@ -1,44 +0,0 @@
import { useMemo } from "react";
import { Target } from "@phosphor-icons/react";
import type { GoalRecord, SectionId, TaskRecord } from "@shared/types";
export function GoalProgress({ goals, tasks, onNavigate }: { goals: GoalRecord[]; tasks: TaskRecord[]; onNavigate: (section: SectionId) => void }) {
const activeGoals = useMemo(() => goals.filter((g) => g.status === "active"), [goals]);
if (activeGoals.length === 0) return null;
return (
<div className="space-y-2">
{activeGoals.slice(0, 5).map((goal) => {
const goalTasks = tasks.filter((t) => t.goalId === goal.id);
const done = goalTasks.filter((t) => t.status === "done").length;
const total = goalTasks.length;
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
return (
<button
key={goal.id}
type="button"
onClick={() => onNavigate("goals")}
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-4 py-3.5 text-left transition hover:border-border hover:shadow-sm"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Target size={15} weight="fill" className="shrink-0 text-primary" />
<span className="truncate text-[13px] font-medium text-foreground">{goal.title}</span>
</div>
<span className="shrink-0 whitespace-nowrap text-[13px] font-bold tabular-nums text-primary">{pct}%</span>
</div>
<div className="mt-2.5 h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all duration-700 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
<div className="mt-1.5 text-[11px] text-muted-foreground" style={{ fontVariantNumeric: "tabular-nums" }}>{done}/{total} tasks complete</div>
</button>
);
})}
</div>
);
}
@@ -1,109 +0,0 @@
import { useMemo } from "react";
import { motion } from "framer-motion";
import type {
AgentRecord,
AgentMessageRecord,
ApprovalRecord,
BrowserActionRecord,
ConnectorRecord,
RunRecord,
TaskRecord,
WorkflowPipelineRecord,
} from "@shared/types";
import { isConnectorExecutionReady } from "@shared/connector-policy";
interface HealthInputProps {
agents: AgentRecord[];
tasks: TaskRecord[];
runs: RunRecord[];
approvals: ApprovalRecord[];
connectors: ConnectorRecord[];
messages?: AgentMessageRecord[];
browserActions?: BrowserActionRecord[];
workflows?: WorkflowPipelineRecord[];
}
export function computeHealthScore(props: HealthInputProps): number {
let score = 100;
const { agents, tasks, runs, approvals, connectors, messages = [], browserActions = [], workflows = [] } = props;
const failedRuns = runs.filter((r) => r.status === "failed" || r.status === "timed_out");
score -= Math.min(failedRuns.length * 5, 25);
const blockedTasks = tasks.filter((t) => t.status === "blocked");
score -= Math.min(blockedTasks.length * 8, 20);
const pendingApprovals = approvals.filter((a) => a.state === "pending");
score -= Math.min(pendingApprovals.length * 5, 15);
const errorAgents = agents.filter((a) => a.status === "error");
score -= Math.min(errorAgents.length * 10, 20);
const readyConnectors = connectors.filter((connector) => isConnectorExecutionReady(connector));
if (readyConnectors.length === 0 && connectors.length > 0) score -= 15;
const overBudget = agents.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd >= a.budgetMonthlyUsd);
score -= Math.min(overBudget.length * 5, 10);
const urgentMessages = messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident"));
score -= Math.min(urgentMessages.length * 4, 12);
const blockedBrowserActions = browserActions.filter((action) => action.status === "approval_required" || action.status === "failed");
score -= Math.min(blockedBrowserActions.length * 4, 12);
const stalledWorkflows = workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed");
score -= Math.min(stalledWorkflows.length * 5, 15);
return Math.max(0, Math.min(100, score));
}
function healthLabel(score: number): { label: string; tone: string } {
if (score >= 90) return { label: "Excellent", tone: "text-green-600 dark:text-green-400" };
if (score >= 70) return { label: "Good", tone: "text-green-600 dark:text-green-400" };
if (score >= 50) return { label: "Fair", tone: "text-yellow-600 dark:text-yellow-400" };
if (score >= 30) return { label: "Poor", tone: "text-yellow-600 dark:text-yellow-400" };
return { label: "Critical", tone: "text-destructive" };
}
export function HealthRing({ score }: { score: number }) {
const radius = 38;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (score / 100) * circumference;
const { label, tone } = healthLabel(score);
const strokeColor = score >= 70
? "var(--primary)"
: score >= 50
? "var(--destructive)"
: "var(--destructive)";
return (
<div className="flex items-center gap-5">
<div className="relative h-[90px] w-[90px] shrink-0">
<svg viewBox="0 0 88 88" className="h-full w-full -rotate-90">
<circle cx="44" cy="44" r={radius} fill="none" stroke="var(--border)" strokeWidth="6" />
<motion.circle
cx="44"
cy="44"
r={radius}
fill="none"
stroke={strokeColor}
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 1.2, ease: "easeOut" }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className={`text-[22px] font-bold ${tone}`}>{score}</span>
</div>
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Company Health</div>
<div className={`text-[16px] font-semibold ${tone}`}>{label}</div>
</div>
</div>
);
}
@@ -1,147 +0,0 @@
import { useMemo } from "react";
import {
ArrowRight,
Cpu,
Lightning,
ShieldCheck,
Users,
XCircle,
} from "@phosphor-icons/react";
import type {
AgentRecord,
AgentMessageRecord,
ApprovalRecord,
BrowserActionRecord,
RunRecord,
SectionId,
WorkflowPipelineRecord,
} from "@shared/types";
import { useT } from "../../i18n";
export function NeedsAttention({
agents,
approvals,
runs,
messages,
browserActions,
workflows,
onNavigate,
}: {
agents: AgentRecord[];
approvals: ApprovalRecord[];
runs: RunRecord[];
messages: AgentMessageRecord[];
browserActions: BrowserActionRecord[];
workflows: WorkflowPipelineRecord[];
onNavigate: (section: SectionId, entityId?: string) => void;
}) {
const t = useT();
const pendingApprovals = useMemo(() => approvals.filter((a) => a.state === "pending"), [approvals]);
const failedRuns = useMemo(() => runs.filter((r) => r.status === "failed" || r.status === "timed_out" || r.status === "interrupted"), [runs]);
const urgentMessages = useMemo(() => messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident")), [messages]);
const blockedBrowserActions = useMemo(() => browserActions.filter((action) => action.status === "approval_required" || action.status === "failed"), [browserActions]);
const stalledWorkflows = useMemo(() => workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed"), [workflows]);
if (pendingApprovals.length === 0 && failedRuns.length === 0 && urgentMessages.length === 0 && blockedBrowserActions.length === 0 && stalledWorkflows.length === 0) {
return null;
}
return (
<section>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-yellow-600 dark:text-yellow-400">{t("dash.needsAttentionSection")}</div>
<div className="space-y-1.5">
{pendingApprovals.slice(0, 5).map((approval) => {
const requester = agents.find((a) => a.id === approval.requestedByAgentId);
return (
<button
key={approval.id}
type="button"
onClick={() => onNavigate("approvals", approval.id)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-left transition hover:border-opacity-40"
>
<ShieldCheck size={18} className="shrink-0 text-yellow-600 dark:text-yellow-400" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{approval.payloadSummary}</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="uppercase tracking-[0.1em]">{approval.type.replaceAll("_", " ")}</span>
{requester ? <span>by {requester.name}</span> : null}
</div>
</div>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
</button>
);
})}
{failedRuns.slice(0, 3).map((run) => {
const agent = agents.find((a) => a.id === run.agentId);
return (
<button
key={run.id}
type="button"
onClick={() => onNavigate("runs", run.id)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-red-100 dark:bg-red-900/50 px-4 py-3 text-left transition hover:border-opacity-40"
>
<XCircle size={18} className="shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{run.summary || "Run failed"}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">
{agent?.name} — {run.errorMessage?.slice(0, 80) ?? "Unknown error"}
</div>
</div>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
</button>
);
})}
{urgentMessages.slice(0, 3).map((message) => {
const sender = agents.find((agent) => agent.id === message.fromAgentId);
return (
<button
key={message.id}
type="button"
onClick={() => onNavigate("communication", message.id)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-left transition hover:border-opacity-40"
>
<Users size={18} className="shrink-0 text-yellow-600 dark:text-yellow-400" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{message.subject}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">
{sender?.name ?? "Unknown"} — {message.channel} {message.priority === "urgent" ? "· urgent" : ""}
</div>
</div>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
</button>
);
})}
{blockedBrowserActions.slice(0, 3).map((action) => (
<button
key={action.id}
type="button"
onClick={() => onNavigate("social")}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-left transition hover:border-opacity-40"
>
<Cpu size={18} className="shrink-0 text-yellow-600 dark:text-yellow-400" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{action.resultSummary || action.actionType}</div>
<div className="mt-0.5 whitespace-nowrap text-[11px] text-muted-foreground">Browser action {action.status.replaceAll("_", " ")}</div>
</div>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
</button>
))}
{stalledWorkflows.slice(0, 3).map((workflow) => (
<button
key={workflow.id}
type="button"
onClick={() => onNavigate("automation")}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-left transition hover:border-opacity-40"
>
<Lightning size={18} className="shrink-0 text-yellow-600 dark:text-yellow-400" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{workflow.name}</div>
<div className="mt-0.5 whitespace-nowrap text-[11px] text-muted-foreground">Workflow {workflow.status}</div>
</div>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
</button>
))}
</div>
</section>
);
}
@@ -1,78 +0,0 @@
import { Robot } from "@phosphor-icons/react";
import type { AgentRecord } from "@shared/types";
import { getAgentInitials } from "../../lib/formatters";
const statusGlow: Record<string, string> = {
running: "ring-2 ring-[color:var(--primary)] ring-opacity-50",
active: "ring-1 ring-[color:var(--primary)] ring-opacity-30",
idle: "",
paused: "opacity-60",
error: "ring-2 ring-[color:var(--destructive)] ring-opacity-50",
pending_approval: "ring-1 ring-[color:var(--destructive)] ring-opacity-40",
terminated: "opacity-40",
};
function AgentCard({ agent, onClick }: { agent: AgentRecord; onClick: () => void }) {
const initials = getAgentInitials(agent.name);
const isActive = agent.status === "running" || agent.status === "active";
return (
<button
type="button"
onClick={onClick}
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 group relative flex flex-col items-center gap-2 rounded-lg border border-border bg-card px-3 py-3.5 text-center transition duration-150 hover:border-border hover:shadow-sm ${statusGlow[agent.status] ?? ""}`}
>
<div className="relative">
<div className={`flex h-12 w-12 items-center justify-center rounded-full text-[15px] font-bold ${
isActive ? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400" : "bg-muted text-muted-foreground"
}`}>
{initials}
</div>
{agent.status === "running" ? (
<span className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-600 dark:bg-green-400 opacity-40" style={{ willChange: "transform" }} />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-green-600 dark:bg-green-400" />
</span>
) : (
<span
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-[color:var(--card)]"
style={{
backgroundColor: agent.status === "error"
? "var(--destructive)"
: agent.status === "active"
? "var(--primary)"
: agent.status === "pending_approval"
? "var(--destructive)"
: agent.status === "paused" || agent.status === "terminated"
? "var(--muted-foreground)"
: "var(--muted-foreground)",
}}
/>
)}
</div>
<div className="w-full min-w-0">
<div className="truncate text-[12px] font-semibold text-foreground">{agent.name}</div>
<div className="truncate text-[10px] text-muted-foreground">{agent.title || agent.role}</div>
</div>
</button>
);
}
export function WorkforceGrid({ agents, onSelectAgent }: { agents: AgentRecord[]; onSelectAgent: (id: string) => void }) {
if (agents.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-10 text-center">
<Robot size={32} className="mb-3 text-muted-foreground" />
<div className="text-[14px] font-medium text-muted-foreground">No agents hired yet</div>
</div>
);
}
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} onClick={() => onSelectAgent(agent.id)} />
))}
</div>
);
}
@@ -1,69 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import { CalendarCheck, Clock } from "@phosphor-icons/react";
import type { SprintRecord, TaskRecord } from "@shared/types";
import { StatusPill } from "../ui";
export function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRecord[] }) {
const [nowMs, setNowMs] = useState<number | null>(null);
useEffect(() => {
setNowMs(Date.now());
}, []);
const progress = useMemo(() => {
if (sprint.velocityPoints === 0) return 0;
return Math.min(100, Math.round((sprint.completedPoints / sprint.velocityPoints) * 100));
}, [sprint]);
const daysLeft = useMemo(() => {
const end = new Date(sprint.endDate).getTime();
const now = nowMs ?? end;
return Math.max(0, Math.ceil((end - now) / (1000 * 60 * 60 * 24)));
}, [sprint, nowMs]);
const sprintTasks = useMemo(() => {
const done = tasks.filter((t) => t.status === "done").length;
const active = tasks.filter((t) => t.status === "in_progress" || t.status === "in_review").length;
const total = tasks.length;
return { done, active, total };
}, [tasks]);
return (
<div className="rounded-lg border border-border bg-card px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CalendarCheck size={16} className="text-primary" />
<span className="truncate text-[12px] font-semibold text-foreground">{sprint.name}</span>
<StatusPill status={sprint.status} />
</div>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Clock size={12} />
{daysLeft} days left
</div>
</div>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full rounded-full bg-primary"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.8, ease: "easeOut" }}
/>
</div>
<div className="mt-2 flex items-center justify-between text-[10px] text-muted-foreground">
<span style={{ fontVariantNumeric: "tabular-nums" }}>{sprint.completedPoints}/{sprint.velocityPoints} points ({progress}%)</span>
<div className="flex items-center gap-3">
<span className="whitespace-nowrap text-green-600 dark:text-green-400">{sprintTasks.done} done</span>
<span className="whitespace-nowrap text-primary">{sprintTasks.active} active</span>
<span className="whitespace-nowrap">{sprintTasks.total} total</span>
</div>
</div>
{sprint.goal && (
<div className="mt-2 text-[11px] italic text-muted-foreground">"{sprint.goal}"</div>
)}
</div>
);
}
@@ -1,36 +0,0 @@
import { useMemo } from "react";
import { ArrowRight } from "@phosphor-icons/react";
import type { TaskRecord } from "@shared/types";
export function TaskPipeline({ tasks }: { tasks: TaskRecord[] }) {
const stages = useMemo(() => {
const counts = { backlog: 0, todo: 0, in_progress: 0, in_review: 0, done: 0, blocked: 0 };
for (const t of tasks) {
if (t.status in counts) counts[t.status as keyof typeof counts]++;
}
return [
{ label: "Backlog", count: counts.backlog, tone: "text-muted-foreground", bg: "bg-muted" },
{ label: "Todo", count: counts.todo, tone: "text-yellow-600 dark:text-yellow-400", bg: "bg-yellow-100 dark:bg-yellow-900/50" },
{ label: "In Progress", count: counts.in_progress, tone: "text-primary", bg: "bg-accent" },
{ label: "In Review", count: counts.in_review, tone: "text-yellow-600 dark:text-yellow-400", bg: "bg-yellow-100 dark:bg-yellow-900/50" },
{ label: "Done", count: counts.done, tone: "text-green-600 dark:text-green-400", bg: "bg-green-100 dark:bg-green-900/50" },
{ label: "Blocked", count: counts.blocked, tone: "text-destructive", bg: "bg-red-100 dark:bg-red-900/50" },
];
}, [tasks]);
return (
<div className="flex items-center gap-1.5">
{stages.map((stage, i) => (
<div key={stage.label} className="flex items-center gap-1.5">
<div className={`flex items-center gap-2 rounded-lg px-3 py-2 ${stage.bg}`}>
<span className={`text-[18px] font-bold ${stage.tone}`} style={{ fontVariantNumeric: "tabular-nums" }}>{stage.count}</span>
<span className="text-[10px] font-medium uppercase tracking-[0.06em] text-muted-foreground">{stage.label}</span>
</div>
{i < stages.length - 1 ? (
<ArrowRight size={12} className="text-muted-foreground" />
) : null}
</div>
))}
</div>
);
}
+1
View File
@@ -12,6 +12,7 @@ export { WorkspaceInspector } from "./execution/WorkspaceInspector"
export { WorkspaceCreator } from "./execution/WorkspaceCreator"
export { SecretCreator } from "./execution/SecretCreator"
export { SettingsPane } from "./execution/SettingsPane"
export { ModelManagement } from "./execution/ModelManagement"
export { PendingReviewList } from "./execution/PendingReviewList"
export { PolicyList } from "./execution/PolicyList"
export { PolicyFormModal } from "./execution/PolicyFormModal"
@@ -0,0 +1,204 @@
import { useCallback, useEffect, useState } from "react";
import { FloppyDisk, Plus, X as XIcon } from "@phosphor-icons/react";
import type { ConnectorRecord, ProfileSnapshot } from "@shared/types";
import type { AppLocale } from "@shared/locale";
import { unwrap } from "../../lib/desktop";
import { ActionButton } from "../ui";
interface ModelManagementProps {
connectors: ProfileSnapshot["connectors"];
locale: AppLocale;
onSaved: () => Promise<void>;
}
/** Check if a claude_compat connector has been configured (has env bindings). */
function isConfigured(c: ConnectorRecord): boolean {
return c.envBindingText.includes("ANTHROPIC_BASE_URL") || c.envBindingText.includes("ANTHROPIC_API_KEY");
}
function getDisplayName(connector: ConnectorRecord): string {
if (connector.id.startsWith("claude_compat") && connector.notes) return connector.notes;
return connector.label;
}
function CompatModelEditor({
connector,
locale,
onSaved,
}: {
connector: ConnectorRecord;
locale: AppLocale;
onSaved: () => Promise<void>;
}) {
const [models, setModels] = useState<string[]>(() => {
const raw = connector.model ?? "";
return raw.split(",").map((m) => m.trim()).filter(Boolean);
});
const [newModel, setNewModel] = useState("");
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
useEffect(() => {
const raw = connector.model ?? "";
setModels(raw.split(",").map((m) => m.trim()).filter(Boolean));
setDirty(false);
}, [connector.model]);
function addModel() {
const trimmed = newModel.trim();
if (!trimmed || models.includes(trimmed)) return;
setModels((prev) => [...prev, trimmed]);
setNewModel("");
setDirty(true);
}
function removeModel(m: string) {
setModels((prev) => prev.filter((x) => x !== m));
setDirty(true);
}
async function handleSave() {
setSaving(true);
try {
unwrap(await window.agentCompany.saveConnector({
id: connector.id,
command: connector.command,
model: models.join(",") || null,
envBindingText: connector.envBindingText,
notes: connector.notes,
}));
setDirty(false);
await onSaved();
} finally {
setSaving(false);
}
}
return (
<div>
<div className="mb-2 text-[13px] font-semibold text-foreground">{getDisplayName(connector)}</div>
{models.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-1.5">
{models.map((m) => (
<span
key={m}
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted px-2.5 py-1 text-[12px] text-foreground"
>
{m}
<button
type="button"
onClick={() => removeModel(m)}
className="ml-0.5 text-muted-foreground transition hover:text-destructive"
aria-label={`Remove ${m}`}
>
<XIcon size={10} />
</button>
</span>
))}
</div>
) : (
<div className="mb-2 text-[12px] text-muted-foreground">
{locale === "zh" ? "未配置模型" : "No models configured"}
</div>
)}
<div className="flex items-center gap-2">
<input
className="flex-1 rounded-lg border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
placeholder={locale === "zh" ? "输入模型名称" : "model-name"}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }}
/>
<button
type="button"
onClick={addModel}
disabled={!newModel.trim()}
className="shrink-0 rounded-lg border border-border bg-card p-2 text-foreground transition hover:bg-muted disabled:opacity-40"
aria-label="Add model"
>
<Plus size={14} />
</button>
{dirty ? (
<ActionButton
label={locale === "zh" ? "保存" : "Save"}
tone="accent"
icon={FloppyDisk}
onClick={() => void handleSave()}
loading={saving}
/>
) : null}
</div>
</div>
);
}
function NativeModelList({
connector,
locale,
}: {
connector: ConnectorRecord;
locale: AppLocale;
}) {
const [models, setModels] = useState<Array<{ id: string; label: string }>>([]);
const loadModels = useCallback(async () => {
try {
const result = await window.agentCompany.listConnectorModels(connector.id);
if (result.ok && result.data.length > 0) {
setModels(result.data);
}
} catch { /* non-fatal */ }
}, [connector.id]);
useEffect(() => { void loadModels(); }, [loadModels]);
return (
<div>
<div className="mb-2 text-[13px] font-semibold text-foreground">{getDisplayName(connector)}</div>
{models.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{models.map((m) => (
<span
key={m.id}
className="inline-block rounded-md border border-border bg-muted px-2.5 py-1 text-[12px] text-foreground"
>
{m.label}
</span>
))}
</div>
) : (
<div className="text-[12px] text-muted-foreground">
{locale === "zh" ? "使用默认模型" : "Using default model"}
</div>
)}
</div>
);
}
export function ModelManagement({ connectors, locale, onSaved }: ModelManagementProps) {
// Show native connectors that are ready + claude_compat connectors that are configured
const visibleConnectors = connectors.filter((c) => {
if (c.id.startsWith("claude_compat")) return isConfigured(c);
return c.status === "ready";
});
if (visibleConnectors.length === 0) {
return (
<div className="py-6 text-center text-[13px] text-muted-foreground">
{locale === "zh" ? "暂无就绪的提供商" : "No ready providers"}
</div>
);
}
return (
<div className="space-y-5">
{visibleConnectors.map((connector) =>
connector.id.startsWith("claude_compat") ? (
<CompatModelEditor key={connector.id} connector={connector} locale={locale} onSaved={onSaved} />
) : (
<NativeModelList key={connector.id} connector={connector} locale={locale} />
),
)}
</div>
);
}
@@ -21,7 +21,7 @@ export function TaskCreator({ companyId, goals, projects, agents, workspaces, on
}
return (
<div className="grid gap-4 md:grid-cols-2">
<div className="grid min-w-0 gap-4 md:grid-cols-2">
<Input label={t("exec.title")} value={title} onChange={setTitle} placeholder={t("exec.taskTitle")} />
<Select label={t("exec.priority")} value={priority} onChange={setPriority} options={["critical","high","medium","low"].map((v) => ({ value: v, label: v }))} />
<div className="md:col-span-2"><TextArea label="Description" value={description} onChange={setDescription} placeholder="Describe the task" rows={5} /></div>
@@ -42,7 +42,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
{actionError ? <InlineNotice message={actionError} /> : null}
{!actionError && actionNotice ? <InlineNotice message={actionNotice} tone="success" /> : null}
{editing ? (
<div className="grid gap-3 md:grid-cols-2">
<div className="grid min-w-0 gap-3 md:grid-cols-2">
<div className="md:col-span-2"><Input label={t("exec.title")} value={editTitle} onChange={setEditTitle} placeholder={t("exec.taskTitle")} /></div>
<div className="md:col-span-2"><TextArea label={t("exec.description")} value={editDescription} onChange={setEditDescription} placeholder={t("exec.description")} rows={4} /></div>
<Select label={t("exec.priority")} value={editPriority} onChange={v => setEditPriority(v as TaskRecord["priority"])} options={["critical","high","medium","low"].map(v => ({ value: v, label: v }))} />
@@ -66,7 +66,7 @@ export function AgentCreator({
}, [executionReadyConnectors, connectorId]);
return (
<div className="grid gap-4">
<div className="grid min-w-0 gap-4">
<Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" />
<Input label="Role" value={role} onChange={setRole} placeholder="e.g. Engineer, PM, CEO" />
<Input label="Title" value={title} onChange={setTitle} placeholder="Title" />
@@ -74,7 +74,7 @@ export function AgentCreator({
label="Connector"
value={connectorId}
onChange={(value) => setConnectorId(value as ConnectorRecord["id"])}
options={executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.label }))}
options={executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector.label }))}
/>
{executionReadyConnectors.length === 0 ? (
<p className="text-[12px] text-yellow-600 dark:text-yellow-400">
@@ -4,6 +4,7 @@ import type { AgentRecord, BudgetPolicyRecord, ConnectorRecord, WorkspaceRecord
import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy";
import { unwrap } from "../../lib/desktop";
import { useAppStore } from "../../state/store";
import { useT } from "../../i18n";
import { ActionButton, Input, Select, TextArea } from "../ui";
import { SkillAgentToggles } from "../skills/SkillAgentToggles";
import { ConfirmDelete, HeartbeatSection } from "./helpers";
@@ -25,6 +26,7 @@ export function AgentEditor({
onSaved: () => Promise<void>;
onDeleted: () => Promise<void>;
}) {
const t = useT();
const [name, setName] = useState(agent.name);
const [role, setRole] = useState(agent.role);
const [title, setTitle] = useState(agent.title);
@@ -51,30 +53,43 @@ export function AgentEditor({
const persistedConnectorRecord = connectors.find((connector) => connector.id === agent.connectorId) ?? null;
const persistedConnectorIssue = persistedConnectorRecord ? getConnectorExecutionReadinessIssue(persistedConnectorRecord) : "Current connector is unavailable.";
const connectorOptions = useMemo(() => {
const readyOptions = executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.label }));
const getDisplayLabel = (c: typeof connectors[number]) =>
c.id.startsWith("claude_compat") && c.notes ? c.notes : c.label;
const readyOptions = executionReadyConnectors.map((connector) => ({ value: connector.id, label: getDisplayLabel(connector) }));
if (!selectedConnectorRecord || isConnectorExecutionReady(selectedConnectorRecord)) {
return readyOptions;
}
return [
{ value: selectedConnectorRecord.id, label: `${selectedConnectorRecord.label} (current, blocked)` },
{ value: selectedConnectorRecord.id, label: `${getDisplayLabel(selectedConnectorRecord)} (current, blocked)` },
...readyOptions.filter((option) => option.value !== selectedConnectorRecord.id),
];
}, [executionReadyConnectors, selectedConnectorRecord]);
useEffect(() => {
void (async () => {
const result = await window.agentCompany.listConnectorModels(connectorId);
if (result.ok) {
setAvailableModels(result.data);
if (connectorId !== agent.connectorId) {
setModel(result.data[0]?.id ?? "");
}
if (connectorId.startsWith("claude_compat")) {
// Parse comma-separated user-configured models
const compat = connectors.find((c) => c.id === connectorId);
const raw = compat?.model ?? "";
const models = raw.split(",").map((m) => m.trim()).filter(Boolean).map((m) => ({ id: m, label: m }));
setAvailableModels(models);
if (connectorId !== agent.connectorId && models.length > 0) {
setModel(models[0].id);
}
})();
if (connectorId !== "claude_local" && connectorId !== "codex_local") {
} else {
void (async () => {
const result = await window.agentCompany.listConnectorModels(connectorId);
if (result.ok) {
setAvailableModels(result.data);
if (connectorId !== agent.connectorId) {
setModel(result.data[0]?.id ?? "");
}
}
})();
}
if (connectorId !== "claude_local" && !connectorId.startsWith("claude_compat") && connectorId !== "codex_local") {
setThinkingEffort("");
}
}, [connectorId, agent.connectorId]);
}, [connectorId, agent.connectorId, connectors]);
const skills = useAppStore((s) => s.skills);
const setSkills = useAppStore((s) => s.setSkills);
@@ -107,17 +122,17 @@ export function AgentEditor({
const managerCandidates = agents.filter((a) => a.id !== agent.id);
return (
<div className="grid gap-4">
<Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" readOnly={pendingApproval} disabled={pendingApproval} />
<Input label="Role" value={role} onChange={setRole} placeholder="Role" readOnly={pendingApproval} disabled={pendingApproval} />
<Input label="Title" value={title} onChange={setTitle} placeholder="Title" readOnly={pendingApproval} disabled={pendingApproval} />
<div className="grid min-w-0 gap-4">
<Input label={t("agent.name")} value={name} onChange={setName} placeholder={t("agent.name")} readOnly={pendingApproval} disabled={pendingApproval} />
<Input label={t("agent.role")} value={role} onChange={setRole} placeholder={t("agent.role")} readOnly={pendingApproval} disabled={pendingApproval} />
<Input label={t("agent.title")} value={title} onChange={setTitle} placeholder={t("agent.title")} readOnly={pendingApproval} disabled={pendingApproval} />
<Select
label="Status"
label={t("agent.status")}
value={status}
onChange={(v) => setStatus(v as AgentRecord["status"])}
disabled={pendingApproval}
options={pendingApproval
? [{ value: "pending_approval", label: "Pending Approval" }]
? [{ value: "pending_approval", label: t("agent.pendingApproval") }]
: [
{ value: "idle", label: "Idle" },
{ value: "active", label: "Active" },
@@ -129,11 +144,11 @@ export function AgentEditor({
/>
{pendingApproval ? (
<p className="text-[12px] text-muted-foreground">
Pending hires must be approved or rejected from Hiring or Approvals. Status changes are intentionally locked here.
{t("agent.pendingHireNote")}
</p>
) : null}
<Select
label="Connector"
label={t("agent.connector")}
value={connectorId}
onChange={(v) => setConnectorId(v as ConnectorRecord["id"])}
disabled={pendingApproval}
@@ -146,7 +161,7 @@ export function AgentEditor({
) : null}
{availableModels.length > 0 ? (
<Select
label="Model"
label={t("agent.model")}
value={model}
onChange={setModel}
disabled={pendingApproval}
@@ -155,7 +170,7 @@ export function AgentEditor({
) : null}
{supportsEffort ? (
<Select
label="Thinking effort"
label={t("agent.thinkingEffort")}
value={thinkingEffort}
onChange={setThinkingEffort}
disabled={pendingApproval}
@@ -168,39 +183,39 @@ export function AgentEditor({
/>
) : null}
<Select
label="Workspace"
label={t("agent.workspace")}
value={workspaceId}
onChange={setWorkspaceId}
disabled={pendingApproval}
options={[
{ value: "", label: "No workspace" },
{ value: "", label: t("agent.noWorkspace") },
...workspaces.map((w) => ({ value: w.id, label: w.name })),
]}
/>
<Select
label="Reports to"
label={t("agent.reportsTo")}
value={reportsTo}
onChange={setReportsTo}
disabled={pendingApproval}
options={[
{ value: "", label: "No manager" },
{ value: "", label: t("agent.noManager") },
...managerCandidates.map((a) => ({ value: a.id, label: a.name })),
]}
/>
<Input label="Monthly budget (USD)" value={budget} onChange={setBudget} placeholder="120" type="number" readOnly={pendingApproval} disabled={pendingApproval} />
<TextArea label="Capabilities" value={capabilities} onChange={setCapabilities} placeholder="Capabilities" readOnly={pendingApproval} disabled={pendingApproval} />
<Input label={t("agent.budget")} value={budget} onChange={setBudget} placeholder="120" type="number" readOnly={pendingApproval} disabled={pendingApproval} />
<TextArea label={t("agent.capabilities")} value={capabilities} onChange={setCapabilities} placeholder={t("agent.capabilities")} readOnly={pendingApproval} disabled={pendingApproval} />
<HeartbeatSection agent={agent} companyId={companyId} pendingApproval={pendingApproval} persistedConnectorIssue={persistedConnectorIssue} onSaved={onSaved} />
{/* Skills section (D-04 dual access) */}
<div className="mt-6 border-t border-border pt-4">
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-primary mb-3">Skills</h3>
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-primary mb-3">{t("agent.skills")}</h3>
<SkillAgentToggles mode="agent" companyId={companyId} agentId={agent.id} skills={skills} />
</div>
<div className="flex items-center gap-3">
<ActionButton
label="Save changes"
label={t("agent.saveChanges")}
tone="accent"
icon={Pencil}
disabled={pendingApproval || !!selectedConnectorIssue}
@@ -96,7 +96,7 @@ export function AgentRoster({
<StatusPill status={agent.status} />
</div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>Connector: {connector?.label ?? agent.connectorId}</span>
<span>Connector: {connector?.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector?.label ?? agent.connectorId}</span>
{workspace && <span>Workspace: {workspace.name}</span>}
</div>
<div className="mt-1.5">
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Heartbeat } from "@phosphor-icons/react";
import type { AgentRecord } from "@shared/types";
import type { AgentRecord, ConnectorRecord } from "@shared/types";
import { getAgentInitials } from "../../lib/formatters";
import { EmptyState } from "../ui";
import { useT } from "../../i18n";
import { buildOrgTree, layoutForest, flattenLayout, collectEdges, statusDotColor, adapterLabels, CARD_W, CARD_H, CHART_PADDING } from "./orgTreeLayout";
export function OrgChart({ agents, onSelectAgent }: { agents: AgentRecord[]; onSelectAgent?: (agentId: string) => void }) {
export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentRecord[]; connectors?: ConnectorRecord[]; onSelectAgent?: (agentId: string) => void }) {
const t = useT();
const containerRef = useRef<HTMLDivElement>(null);
const [pan, setPan] = useState({ x: 0, y: 0 });
@@ -92,7 +92,10 @@ export function OrgChart({ agents, onSelectAgent }: { agents: AgentRecord[]; onS
<div className="absolute inset-0" style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, transformOrigin: "0 0" }}>
{allNodes.map((node) => {
const dotColor = statusDotColor[node.agent.status] ?? "#a3a3a3";
const connectorLabel = adapterLabels[node.agent.connectorId] ?? node.agent.connectorId;
const connectorRecord = connectors?.find((c) => c.id === node.agent.connectorId);
const connectorLabel = (connectorRecord?.id.startsWith("claude_compat") && connectorRecord.notes)
? connectorRecord.notes
: adapterLabels[node.agent.connectorId] ?? node.agent.connectorId;
const initials = getAgentInitials(node.agent.name);
const isRunning = node.agent.status === "running";
const isActive = node.agent.status === "active" || isRunning;
@@ -97,6 +97,16 @@ export const statusDotColor: Record<string, string> = {
export const adapterLabels: Record<string, string> = {
claude_local: "Claude",
claude_compatible: "Compatible",
claude_compat_2: "Compatible 2",
claude_compat_3: "Compatible 3",
claude_compat_4: "Compatible 4",
claude_compat_5: "Compatible 5",
claude_compat_6: "Compatible 6",
claude_compat_7: "Compatible 7",
claude_compat_8: "Compatible 8",
claude_compat_9: "Compatible 9",
claude_compat_10: "Compatible 10",
codex_local: "Codex",
gemini_local: "Gemini",
};
@@ -1,4 +1,3 @@
import type { ReactNode } from "react";
import type { RunRecord } from "@shared/types";
export const LOG_CHUNK_BYTES = 65_536;
@@ -27,46 +26,3 @@ export function isActiveStatus(status: RunRecord["status"]) {
return status === "queued" || status === "running";
}
export function isAttentionStatus(status: RunRecord["status"]) {
return status === "failed" || status === "timed_out" || status === "interrupted";
}
export function RunSummaryCard({
label,
value,
detail,
icon: Icon,
tone = "default",
}: {
label: string;
value: number;
detail: string;
icon: React.ElementType;
tone?: "default" | "danger";
}) {
return (
<div className="rounded-lg border border-border bg-card px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground">{label}</div>
<div className={`mt-1 text-[28px] font-semibold tracking-[-0.03em] ${tone === "danger" ? "text-destructive" : "text-foreground"}`}>
{value}
</div>
<div className="mt-1 text-[12px] text-muted-foreground">{detail}</div>
</div>
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${tone === "danger" ? "bg-red-100 dark:bg-red-900/50 text-destructive" : "bg-muted text-muted-foreground"}`}>
<Icon size={18} />
</div>
</div>
</div>
);
}
export function RunMeta({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="rounded-lg border border-border bg-muted px-3.5 py-3">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{label}</div>
<div className="mt-1 text-[13px] text-foreground">{value}</div>
</div>
);
}
+11 -10
View File
@@ -129,21 +129,21 @@ export function Modal({
aria-modal="true"
aria-label={title}
className="flex w-full flex-col rounded-lg border border-border bg-card shadow-xl"
style={{ maxWidth: width, maxHeight: "80vh" }}
style={{ maxWidth: width, maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3.5">
<h2 className="text-[16px] font-bold tracking-[-0.015em] text-foreground">{title}</h2>
<h2 className="min-w-0 truncate text-[16px] font-bold tracking-[-0.015em] text-foreground">{title}</h2>
<button
type="button"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-lg p-1 text-muted-foreground transition hover:text-foreground"
onClick={onClose}
className="ml-3 shrink-0 cursor-pointer rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={(e) => { e.stopPropagation(); onClose(); }}
aria-label="Close"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>
<svg width="16" height="16" viewBox="0 0 14 14" fill="none"><path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>
</button>
</div>
<div className="overflow-y-auto px-5 py-4">{children}</div>
<div className="min-h-0 min-w-0 overflow-y-auto overflow-x-auto px-5 py-4" style={{ overflowWrap: "break-word", wordBreak: "break-word" }}>{children}</div>
</div>
</div>
);
@@ -222,7 +222,8 @@ export function TextArea({
}) {
const textarea = (
<textarea
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.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full max-w-full rounded-lg border border-border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60 break-words"
style={{ overflowWrap: "anywhere", wordBreak: "break-word", whiteSpace: "pre-wrap" }}
value={value}
rows={rows}
placeholder={placeholder}
@@ -259,18 +260,18 @@ export function Select({
}) {
const select = (
<select
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full border bg-card text-foreground ${
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full max-w-full border bg-card text-foreground ${
compact
? "rounded-lg border-border px-2.5 py-1.5 text-[13px] font-medium"
: "rounded-lg border-border px-3.5 py-2.5 text-[14px]"
} disabled:cursor-not-allowed disabled:opacity-60`}
} disabled:cursor-not-allowed disabled:opacity-60 truncate`}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
>
{options.map((option) => (
<option key={option.value} value={option.value} className="bg-card text-foreground">
{option.label}
{option.label.length > 80 ? option.label.slice(0, 77) + "..." : option.label}
</option>
))}
</select>
+54
View File
@@ -121,6 +121,60 @@ export const en: Record<string, string> = {
"onboarding.connectorWarning": "Install and authenticate a CLI runtime first.",
"onboarding.languageSelection": "Language",
"onboarding.selectLanguage": "Select language",
"onboarding.welcomeSubtitle": "Your local-first AI agent company starts here.",
"onboarding.getStarted": "Get Started",
"onboarding.stepLabelNodeCheck": "Requirements",
"onboarding.stepLabelProviders": "Providers",
"onboarding.stepLabelCompany": "Company",
"onboarding.stepLabelMission": "Mission",
"onboarding.stepLabelLaunch": "Launch",
"onboarding.nodeCheckTitle": "Check Node.js",
"onboarding.nodeCheckStep": "SYSTEM REQUIREMENTS",
"onboarding.nodeInstalled": "Node.js {version} installed",
"onboarding.nodeNotInstalled": "Node.js is not installed",
"onboarding.nodeRequired": "Node.js is required to run CLI-based agent connectors.",
"onboarding.checkAgain": "Check Again",
"onboarding.installNodeTerminal": "Open Terminal & Install",
"onboarding.providersTitle": "Configure AI Providers",
"onboarding.providersSubtitle": "Set up at least one provider to power your agents. You can configure more later in Settings.",
"onboarding.providersStep": "AI PROVIDERS",
"onboarding.providerReady": "Ready",
"onboarding.providerNotInstalled": "Not installed",
"onboarding.providerAuthRequired": "Auth required",
"onboarding.providerChecking": "Checking",
"onboarding.providerInstall": "Install",
"onboarding.providerLogin": "Login",
"onboarding.providerCheck": "Check",
"onboarding.providerSaveCheck": "Save & Check",
"onboarding.providerApiKey": "API Key",
"onboarding.providerBaseUrl": "Base URL",
"onboarding.providerModel": "Model",
"onboarding.providerSelectProvider": "Select a provider...",
"onboarding.providerViewDocs": "View docs",
"onboarding.providerCompatibleNote": "This overrides Claude Code Official API key settings.",
"onboarding.providerAtLeastOne": "Configure at least one provider to continue.",
"onboarding.claudeCodeLabel": "Claude Code",
"onboarding.codexLabel": "Codex",
"onboarding.geminiLabel": "Gemini CLI",
"onboarding.claudeCompatibleLabel": "Claude Code Compatible API",
"agent.name": "Agent name",
"agent.role": "Role",
"agent.title": "Title",
"agent.status": "Status",
"agent.connector": "Connector",
"agent.model": "Model",
"agent.thinkingEffort": "Thinking effort",
"agent.workspace": "Workspace",
"agent.reportsTo": "Reports to",
"agent.noManager": "No manager",
"agent.noWorkspace": "No workspace",
"agent.budget": "Monthly budget (USD)",
"agent.capabilities": "Capabilities",
"agent.saveChanges": "Save changes",
"agent.delete": "Delete agent",
"agent.skills": "Skills",
"agent.pendingApproval": "Pending Approval",
"agent.pendingHireNote": "Pending hires must be approved or rejected from Hiring or Approvals.",
// ── Settings ──
"settings.profile": "Profile",
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more