diff --git a/.gitignore b/.gitignore index d86c7adf..4fbdb4c1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index a86b9832..c1dcf26a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + +## 核心原则 (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 拆解为 `///`。 + +## 工程规范 (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。 diff --git a/build/electron-builder.runtime.json b/build/electron-builder.runtime.json index 3e0547bf..5e1a7c60 100644 --- a/build/electron-builder.runtime.json +++ b/build/electron-builder.runtime.json @@ -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" } } diff --git a/build/release/builder-debug.yml b/build/release/builder-debug.yml index 5ba1b9fe..5027d739 100644 --- a/build/release/builder-debug.yml +++ b/build/release/builder-debug.yml @@ -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::SetEnvironmentVariable(t, t)i (\"PORTABLE_EXECUTABLE_DIR\", \"$EXEDIR\").r0'\n System::Call 'Kernel32::SetEnvironmentVariable(t, t)i (\"PORTABLE_EXECUTABLE_FILE\", \"$EXEPATH\").r0'\n System::Call 'Kernel32::SetEnvironmentVariable(t, t)i (\"PORTABLE_EXECUTABLE_APP_FILENAME\", \"${APP_FILENAME}\").r0'\n ${StdUtils.GetAllParameters} $R0 0\n\n !ifdef SPLASH_IMAGE\n BgImage::Destroy\n !endif\n\n\tExecWait \"$INSTDIR\\${APP_EXECUTABLE_FILENAME} $R0\" $0\n SetErrorLevel $0\n\n SetOutPath $EXEDIR\n\tRMDir /r $INSTDIR\nSectionEnd\n" + 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::Call 'Kernel32::SetEnvironmentVariable(t, t)i (\"PORTABLE_EXECUTABLE_FILE\", \"$EXEPATH\").r0'\n System::Call 'Kernel32::SetEnvironmentVariable(t, t)i (\"PORTABLE_EXECUTABLE_APP_FILENAME\", \"${APP_FILENAME}\").r0'\n ${StdUtils.GetAllParameters} $R0 0\n\n !ifdef SPLASH_IMAGE\n BgImage::Destroy\n !endif\n\n\tExecWait \"$INSTDIR\\${APP_EXECUTABLE_FILENAME} $R0\" $0\n SetErrorLevel $0\n\n SetOutPath $EXEDIR\n\tRMDir /r $INSTDIR\nSectionEnd\n" diff --git a/build/release/win-arm64-unpacked/resources/app.asar b/build/release/win-arm64-unpacked/resources/app.asar index ec79ea81..4a3fd83e 100644 Binary files a/build/release/win-arm64-unpacked/resources/app.asar and b/build/release/win-arm64-unpacked/resources/app.asar differ diff --git a/package.json b/package.json index 3211a222..007b2a5a 100644 --- a/package.json +++ b/package.json @@ -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.", diff --git a/packages/adapter-claude-local/src/cli/format-event.ts b/packages/adapter-claude-local/src/cli/format-event.ts index 08423d6e..30fe9d13 100644 --- a/packages/adapter-claude-local/src/cli/format-event.ts +++ b/packages/adapter-claude-local/src/cli/format-event.ts @@ -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; - 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(); diff --git a/packages/adapter-claude-local/src/server/index.ts b/packages/adapter-claude-local/src/server/index.ts index 41e20205..702c28cd 100644 --- a/packages/adapter-claude-local/src/server/index.ts +++ b/packages/adapter-claude-local/src/server/index.ts @@ -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; - 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 | 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 | null) { - if (!params) return null; - return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); - }, -}; +export const sessionCodec = createStandardSessionCodec(); diff --git a/packages/adapter-claude-local/src/server/test.ts b/packages/adapter-claude-local/src/server/test.ts index 239635ed..a636f911 100644 --- a/packages/adapter-claude-local/src/server/test.ts +++ b/packages/adapter-claude-local/src/server/test.ts @@ -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 { diff --git a/packages/adapter-claude-local/src/ui/build-config.ts b/packages/adapter-claude-local/src/ui/build-config.ts index 5835ac47..595b528b 100644 --- a/packages/adapter-claude-local/src/ui/build-config.ts +++ b/packages/adapter-claude-local/src/ui/build-config.ts @@ -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 { - const env: Record = {}; - 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 { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - 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; - 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 | 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; - } catch { - return null; - } -} +import { parseCommaArgs, parseEnvVars, parseEnvBindings, parseJsonObject } from "@agentcompany/adapter-utils/config-utils"; export function buildClaudeLocalConfig(v: CreateConfigValues): Record { const ac: Record = {}; diff --git a/packages/adapter-claude-local/src/ui/parse-stdout.ts b/packages/adapter-claude-local/src/ui/parse-stdout.ts index cca5fd41..fda73ee2 100644 --- a/packages/adapter-claude-local/src/ui/parse-stdout.ts +++ b/packages/adapter-claude-local/src/ui/parse-stdout.ts @@ -1,39 +1,10 @@ import type { TranscriptEntry } from "@agentcompany/adapter-utils"; - -function asRecord(value: unknown): Record | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - return value as Record; -} +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) { diff --git a/packages/adapter-codex-local/src/cli/format-event.ts b/packages/adapter-codex-local/src/cli/format-event.ts index 507d7624..c6294097 100644 --- a/packages/adapter-codex-local/src/cli/format-event.ts +++ b/packages/adapter-codex-local/src/cli/format-event.ts @@ -1,34 +1,5 @@ import pc from "picocolors"; - -function asRecord(value: unknown): Record | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - return value as Record; -} - -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): boolean { const itemType = asString(item.type); diff --git a/packages/adapter-codex-local/src/server/execute.ts b/packages/adapter-codex-local/src/server/execute.ts index 2c99cef8..edbcdff8 100644 --- a/packages/adapter-codex-local/src/server/execute.ts +++ b/packages/adapter-codex-local/src/server/execute.ts @@ -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, key: string): boolean { const raw = env[key]; return typeof raw === "string" && raw.trim().length > 0; diff --git a/packages/adapter-codex-local/src/server/index.ts b/packages/adapter-codex-local/src/server/index.ts index da878bde..869b35fb 100644 --- a/packages/adapter-codex-local/src/server/index.ts +++ b/packages/adapter-codex-local/src/server/index.ts @@ -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; - 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 | 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 | null) { - if (!params) return null; - return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); - }, -}; +export const sessionCodec = createStandardSessionCodec(); diff --git a/packages/adapter-codex-local/src/server/test.ts b/packages/adapter-codex-local/src/server/test.ts index 5e62b96b..3284f211 100644 --- a/packages/adapter-codex-local/src/server/test.ts +++ b/packages/adapter-codex-local/src/server/test.ts @@ -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; diff --git a/packages/adapter-codex-local/src/ui/build-config.ts b/packages/adapter-codex-local/src/ui/build-config.ts index f8e86978..b72af635 100644 --- a/packages/adapter-codex-local/src/ui/build-config.ts +++ b/packages/adapter-codex-local/src/ui/build-config.ts @@ -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 { - const env: Record = {}; - 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 { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - 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; - 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 | 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; - } catch { - return null; - } -} - export function buildCodexLocalConfig(v: CreateConfigValues): Record { const ac: Record = {}; if (v.cwd) ac.cwd = v.cwd; diff --git a/packages/adapter-codex-local/src/ui/parse-stdout.ts b/packages/adapter-codex-local/src/ui/parse-stdout.ts index 1ca4025c..68360397 100644 --- a/packages/adapter-codex-local/src/ui/parse-stdout.ts +++ b/packages/adapter-codex-local/src/ui/parse-stdout.ts @@ -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 | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - return value as Record; -} +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 ""; diff --git a/packages/adapter-gemini-local/src/cli/format-event.ts b/packages/adapter-gemini-local/src/cli/format-event.ts index 777ef194..f1623bd5 100644 --- a/packages/adapter-gemini-local/src/cli/format-event.ts +++ b/packages/adapter-gemini-local/src/cli/format-event.ts @@ -1,44 +1,5 @@ import pc from "picocolors"; - -function asRecord(value: unknown): Record | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - return value as Record; -} - -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") { diff --git a/packages/adapter-gemini-local/src/server/execute.ts b/packages/adapter-gemini-local/src/server/execute.ts index 381e0241..5d4b1271 100644 --- a/packages/adapter-gemini-local/src/server/execute.ts +++ b/packages/adapter-gemini-local/src/server/execute.ts @@ -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 { - 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 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 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 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 {}); + } + } } diff --git a/packages/adapter-gemini-local/src/server/index.ts b/packages/adapter-gemini-local/src/server/index.ts index 8ffe7d56..c4538a48 100644 --- a/packages/adapter-gemini-local/src/server/index.ts +++ b/packages/adapter-gemini-local/src/server/index.ts @@ -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; - 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 | 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 | null) { - if (!params) return null; - return ( - readNonEmptyString(params.sessionId) ?? - readNonEmptyString(params.session_id) ?? - readNonEmptyString(params.sessionID) - ); - }, -}; +export const sessionCodec = createStandardSessionCodec({ extraSessionIdKeys: ["sessionID"] }); diff --git a/packages/adapter-gemini-local/src/server/test.ts b/packages/adapter-gemini-local/src/server/test.ts index 0b7406bf..effd49ce 100644 --- a/packages/adapter-gemini-local/src/server/test.ts +++ b/packages/adapter-gemini-local/src/server/test.ts @@ -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, diff --git a/packages/adapter-gemini-local/src/server/utils.ts b/packages/adapter-gemini-local/src/server/utils.ts deleted file mode 100644 index fb11c75d..00000000 --- a/packages/adapter-gemini-local/src/server/utils.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function firstNonEmptyLine(text: string): string { - return ( - text - .split(/\r?\n/) - .map((line) => line.trim()) - .find(Boolean) ?? "" - ); -} diff --git a/packages/adapter-gemini-local/src/ui/build-config.ts b/packages/adapter-gemini-local/src/ui/build-config.ts index ad8ef161..d38af272 100644 --- a/packages/adapter-gemini-local/src/ui/build-config.ts +++ b/packages/adapter-gemini-local/src/ui/build-config.ts @@ -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 { - const env: Record = {}; - 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 { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - 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; - 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 { const ac: Record = {}; if (v.cwd) ac.cwd = v.cwd; diff --git a/packages/adapter-gemini-local/src/ui/parse-stdout.ts b/packages/adapter-gemini-local/src/ui/parse-stdout.ts index cc9d1c75..79e7303a 100644 --- a/packages/adapter-gemini-local/src/ui/parse-stdout.ts +++ b/packages/adapter-gemini-local/src/ui/parse-stdout.ts @@ -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 | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - return value as Record; -} +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(); diff --git a/packages/adapter-kimi-local/src/server/execute.ts b/packages/adapter-kimi-local/src/server/execute.ts index e665ed6d..15f8d35e 100644 --- a/packages/adapter-kimi-local/src/server/execute.ts +++ b/packages/adapter-kimi-local/src/server/execute.ts @@ -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 { - if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; - const env: Record = {}; - for (const [key, value] of Object.entries(input as Record)) { - if (typeof value === "string") env[key] = value; - } - return env; -} - export async function execute(ctx: AdapterExecutionContext): Promise { const { runId, config, context, onLog } = ctx; const command = asString(config.command, "kimi"); diff --git a/packages/adapter-kimi-local/src/server/test.ts b/packages/adapter-kimi-local/src/server/test.ts index e0b62523..1617ae8d 100644 --- a/packages/adapter-kimi-local/src/server/test.ts +++ b/packages/adapter-kimi-local/src/server/test.ts @@ -7,15 +7,7 @@ import { ensureCommandResolvable, ensurePathInEnv, } from "@agentcompany/adapter-utils/server-utils"; - -function normalizeEnv(input: unknown): Record { - if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; - const env: Record = {}; - for (const [key, value] of Object.entries(input as Record)) { - if (typeof value === "string") env[key] = value; - } - return env; -} +import { normalizeEnv } from "@agentcompany/adapter-utils/test-utils"; export async function testEnvironment( ctx: AdapterEnvironmentTestContext, diff --git a/packages/adapter-kimi-local/src/ui/build-config.ts b/packages/adapter-kimi-local/src/ui/build-config.ts index fc083809..fac4517d 100644 --- a/packages/adapter-kimi-local/src/ui/build-config.ts +++ b/packages/adapter-kimi-local/src/ui/build-config.ts @@ -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 { - const env: Record = {}; - 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 { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - 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; - 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 { const config: Record = {}; if (v.cwd) config.cwd = v.cwd; diff --git a/packages/adapter-opencode-local/src/cli/format-event.ts b/packages/adapter-opencode-local/src/cli/format-event.ts index 58d2038d..9cd6f97c 100644 --- a/packages/adapter-opencode-local/src/cli/format-event.ts +++ b/packages/adapter-opencode-local/src/cli/format-event.ts @@ -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 | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - return value as Record; -} - -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(); diff --git a/packages/adapter-opencode-local/src/server/execute.ts b/packages/adapter-opencode-local/src/server/execute.ts index 68823dd4..c765182a 100644 --- a/packages/adapter-opencode-local/src/server/execute.ts +++ b/packages/adapter-opencode-local/src/server/execute.ts @@ -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(); diff --git a/packages/adapter-opencode-local/src/server/index.ts b/packages/adapter-opencode-local/src/server/index.ts index 5b5b9e65..9865584d 100644 --- a/packages/adapter-opencode-local/src/server/index.ts +++ b/packages/adapter-opencode-local/src/server/index.ts @@ -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; - 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 | 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 | 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"; diff --git a/packages/adapter-opencode-local/src/server/models.ts b/packages/adapter-opencode-local/src/server/models.ts index b10e759f..7c5c3eca 100644 --- a/packages/adapter-opencode-local/src/server/models.ts +++ b/packages/adapter-opencode-local/src/server/models.ts @@ -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/)) { diff --git a/packages/adapter-opencode-local/src/server/test.ts b/packages/adapter-opencode-local/src/server/test.ts index 86322b52..1b31f515 100644 --- a/packages/adapter-opencode-local/src/server/test.ts +++ b/packages/adapter-opencode-local/src/server/test.ts @@ -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 { - if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; - const env: Record = {}; - for (const [key, value] of Object.entries(input as Record)) { - 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; diff --git a/packages/adapter-opencode-local/src/ui/build-config.ts b/packages/adapter-opencode-local/src/ui/build-config.ts index c8784140..cd76e9af 100644 --- a/packages/adapter-opencode-local/src/ui/build-config.ts +++ b/packages/adapter-opencode-local/src/ui/build-config.ts @@ -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 { - const env: Record = {}; - 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 { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - 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; - 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 { const ac: Record = {}; diff --git a/packages/adapter-opencode-local/src/ui/parse-stdout.ts b/packages/adapter-opencode-local/src/ui/parse-stdout.ts index bf495040..71973340 100644 --- a/packages/adapter-opencode-local/src/ui/parse-stdout.ts +++ b/packages/adapter-opencode-local/src/ui/parse-stdout.ts @@ -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 | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - return value as Record; -} +import { asRecord, safeJsonParse } from "@agentcompany/adapter-utils/parse-utils"; function asString(value: unknown, fallback = ""): string { return typeof value === "string" ? value : fallback; diff --git a/packages/adapter-utils/src/config-utils.ts b/packages/adapter-utils/src/config-utils.ts new file mode 100644 index 00000000..cf5a26cf --- /dev/null +++ b/packages/adapter-utils/src/config-utils.ts @@ -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 { + const env: Record = {}; + 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 { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + 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; + 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 | 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; + } catch { + return null; + } +} diff --git a/packages/adapter-utils/src/parse-utils.ts b/packages/adapter-utils/src/parse-utils.ts new file mode 100644 index 00000000..a54ec734 --- /dev/null +++ b/packages/adapter-utils/src/parse-utils.ts @@ -0,0 +1,47 @@ +export function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +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); + } +} diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 31255ef4..af964e6f 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -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. diff --git a/packages/adapter-utils/src/session-codec.ts b/packages/adapter-utils/src/session-codec.ts new file mode 100644 index 00000000..d813984c --- /dev/null +++ b/packages/adapter-utils/src/session-codec.ts @@ -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 | null { + return ( + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id) ?? + extraKeys.reduce((found, key) => found ?? readNonEmptyString(record[key]), null) + ); + } + + function extractFields(record: Record) { + 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); + }, + serialize(params: Record | null) { + if (!params) return null; + return extractFields(params); + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return readSessionId(params); + }, + }; +} diff --git a/packages/adapter-utils/src/test-utils.ts b/packages/adapter-utils/src/test-utils.ts new file mode 100644 index 00000000..8cbb8b69 --- /dev/null +++ b/packages/adapter-utils/src/test-utils.ts @@ -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 { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const env: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + if (typeof value === "string") env[key] = value; + } + return env; +} diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 5e1bf93e..064a67bf 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -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; } -// --------------------------------------------------------------------------- -// 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 */ diff --git a/src/main/api-server.ts b/src/main/api-server.ts index de7d106a..a22f3702 100644 --- a/src/main/api-server.ts +++ b/src/main/api-server.ts @@ -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 = { 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[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 : {}; + 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; + 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; + 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; + 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; + 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; + 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; + 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; } } } diff --git a/src/main/automation-service.ts b/src/main/automation-service.ts index 97a8dd19..aa4481d0 100644 --- a/src/main/automation-service.ts +++ b/src/main/automation-service.ts @@ -73,6 +73,126 @@ interface AutomationDependencies { isQuitting: () => boolean; } +const DEPARTMENT_SKILL_KEYWORDS: Record = { + 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; + 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. } } diff --git a/src/main/bootstrap-application.ts b/src/main/bootstrap-application.ts index d9c24865..e08d3948 100644 --- a/src/main/bootstrap-application.ts +++ b/src/main/bootstrap-application.ts @@ -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(); + 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, diff --git a/src/main/browser-action-service.ts b/src/main/browser-action-service.ts index f10e3631..f2dbf402 100644 --- a/src/main/browser-action-service.ts +++ b/src/main/browser-action-service.ts @@ -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 | 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}.`); } diff --git a/src/main/connector-auth-service.ts b/src/main/connector-auth-service.ts index aa936869..731d4291 100644 --- a/src/main/connector-auth-service.ts +++ b/src/main/connector-auth-service.ts @@ -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); } diff --git a/src/main/connector-health-service.ts b/src/main/connector-health-service.ts index 2362e136..f24d7af6 100644 --- a/src/main/connector-health-service.ts +++ b/src/main/connector-health-service.ts @@ -103,27 +103,51 @@ export function createConnectorHealthService({ new Promise((_, 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: diff --git a/src/main/connector-matching.ts b/src/main/connector-matching.ts index 016519a7..63ace854 100644 --- a/src/main/connector-matching.ts +++ b/src/main/connector-matching.ts @@ -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 = { - 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 = { engineering: "code", research: "research", @@ -61,28 +38,37 @@ export const ROLE_DEPARTMENT_HINTS: Record = { legal: "management", }; -/** - * Human-readable capability descriptions per connector. - */ export const CONNECTOR_STRENGTHS: Record = { 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 = 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; diff --git a/src/main/connectors.ts b/src/main/connectors.ts index 96fb5030..917abf5a 100644 --- a/src/main/connectors.ts +++ b/src/main/connectors.ts @@ -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", diff --git a/src/main/database.ts b/src/main/database.ts index 0740bd83..4e4622e8 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -580,9 +580,9 @@ export class AppDatabase { ...companyArgs, ), tasks: this.readAll( - `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( `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( - `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( `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): 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 = 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 = JSON.parse(existingJson); + const incoming: Record = 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(); + 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): 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( "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) { diff --git a/src/main/desktop-shell-service.ts b/src/main/desktop-shell-service.ts index 307d855a..a8564a94 100644 --- a/src/main/desktop-shell-service.ts +++ b/src/main/desktop-shell-service.ts @@ -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 { diff --git a/src/main/host-environment-service.ts b/src/main/host-environment-service.ts index 2e15e998..5cf9192b 100644 --- a/src/main/host-environment-service.ts +++ b/src/main/host-environment-service.ts @@ -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") { diff --git a/src/main/ipc-core-admin-handlers.ts b/src/main/ipc-core-admin-handlers.ts index 3bf9bdb0..47553893 100644 --- a/src/main/ipc-core-admin-handlers.ts +++ b/src/main/ipc-core-admin-handlers.ts @@ -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); diff --git a/src/main/ipc-core-common.ts b/src/main/ipc-core-common.ts index 75db01f8..e98f1da3 100644 --- a/src/main/ipc-core-common.ts +++ b/src/main/ipc-core-common.ts @@ -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; checkConnector: (id: any) => Promise; openConnectorAuthTerminal: (id: any) => Promise; + openGenericTerminal: (input: { title: string; command: string }) => Promise; checkForUpdates: () => Promise; getWorkerProcess: () => { postMessage: (message: unknown) => void } | null; resetAfterRestore: () => void; diff --git a/src/main/ipc-domain-common.ts b/src/main/ipc-domain-common.ts index ca4f8ee0..76ee1efb 100644 --- a/src/main/ipc-domain-common.ts +++ b/src/main/ipc-domain-common.ts @@ -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; 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; diff --git a/src/main/main-runtime-support.ts b/src/main/main-runtime-support.ts index fc030b72..84f7de29 100644 --- a/src/main/main-runtime-support.ts +++ b/src/main/main-runtime-support.ts @@ -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[0]); return definition.listModels ? definition.listModels() : []; }, diff --git a/src/main/onboarding-service.ts b/src/main/onboarding-service.ts index c4ede7f4..ba4e10f9 100644 --- a/src/main/onboarding-service.ts +++ b/src/main/onboarding-service.ts @@ -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; } 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({ diff --git a/src/main/orchestration-service.ts b/src/main/orchestration-service.ts index 57502602..82d3c2b8 100644 --- a/src/main/orchestration-service.ts +++ b/src/main/orchestration-service.ts @@ -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; 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 | null = null; const scheduleLastFiredMap = new Map(); @@ -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 : 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) { 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, diff --git a/src/main/orchestrator/worker.ts b/src/main/orchestrator/worker.ts index e20ff545..d66d0f6b 100644 --- a/src/main/orchestrator/worker.ts +++ b/src/main/orchestrator/worker.ts @@ -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 { } 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); } }); diff --git a/src/main/prompt-builder.ts b/src/main/prompt-builder.ts index 95139745..832662e4 100644 --- a/src/main/prompt-builder.ts +++ b/src/main/prompt-builder.ts @@ -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": ""} + 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: diff --git a/src/main/service-graph.ts b/src/main/service-graph.ts index faf75c30..f6b572ae 100644 --- a/src/main/service-graph.ts +++ b/src/main/service-graph.ts @@ -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({ diff --git a/src/main/skill-format.ts b/src/main/skill-format.ts index c3448b18..1a3949a5 100644 --- a/src/main/skill-format.ts +++ b/src/main/skill-format.ts @@ -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 { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -137,10 +128,6 @@ function parseYamlFrontmatter(raw: string): Record { return isPlainRecord(parsed.value) ? parsed.value : {}; } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - /** * Parse a SKILL.md file with optional YAML frontmatter. * Normalizes CRLF to LF before parsing. diff --git a/src/main/skill-github.ts b/src/main/skill-github.ts index 36c46a72..01172422 100644 --- a/src/main/skill-github.ts +++ b/src/main/skill-github.ts @@ -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. * diff --git a/src/main/skill-keys.ts b/src/main/skill-keys.ts index 15ae0510..27ecb2db 100644 --- a/src/main/skill-keys.ts +++ b/src/main/skill-keys.ts @@ -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_.-]+$/; diff --git a/src/main/skill-service.ts b/src/main/skill-service.ts index 7e227295..a949f890 100644 --- a/src/main/skill-service.ts +++ b/src/main/skill-service.ts @@ -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(data: T): DesktopResult { @@ -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, diff --git a/src/main/skill-types.ts b/src/main/skill-types.ts index 43cfe4f1..9186cd16 100644 --- a/src/main/skill-types.ts +++ b/src/main/skill-types.ts @@ -1,7 +1,3 @@ -// --------------------------------------------------------------------------- -// Skill system type definitions and constants -// --------------------------------------------------------------------------- - /** * Source type for a skill -- matches the company_skills.source_type column. */ diff --git a/src/main/task-quality-gates.ts b/src/main/task-quality-gates.ts index 4e0e61d1..3f1b8e28 100644 --- a/src/main/task-quality-gates.ts +++ b/src/main/task-quality-gates.ts @@ -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) diff --git a/src/main/worker-service.ts b/src/main/worker-service.ts index 9d6ec473..0dc9b0b3 100644 --- a/src/main/worker-service.ts +++ b/src/main/worker-service.ts @@ -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 { diff --git a/src/preload/index.ts b/src/preload/index.ts index 2f4ce660..042a149c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, }; contextBridge.exposeInMainWorld("agentCompany", api); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4270da9b..48924b61 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 ( -
- - {t("app.loading")} +
+ {showCustomTitleBar && } +
+ + {t("app.loading")} +
@@ -705,31 +711,40 @@ export function App() { return ( - 0 && !initialOnboardingLocked} - onCancel={() => { - setShowOnboarding(false); - setInitialOnboardingLocked(false); - setError(null); - }} - currentLocale={locale} - onLocaleChange={handleLocaleChange} - isFirstLaunch={snapshot.companies.length === 0} - /> +
+ {showCustomTitleBar && } +
+ 0 && !initialOnboardingLocked} + onCancel={() => { + setShowOnboarding(false); + setInitialOnboardingLocked(false); + setError(null); + }} + currentLocale={locale} + onLocaleChange={handleLocaleChange} + isFirstLaunch={snapshot.companies.length === 0} + /> +
+
); } + const isMac = !showCustomTitleBar; + return ( -
-