large update
This commit is contained in:
1 parent
2ed952f19c
commit
65e85135c1
111 files changed
+3936
-2933
No files matched your search
+3
-3
@@ -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
|
||||
@@ -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。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}.`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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() : [];
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill system type definitions and constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Source type for a skill -- matches the company_skills.source_type column.
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user