Initialize project and ignore local references
This commit is contained in:
1 parent
a11ac3eaba
commit
2af82b6bb3
132 files changed
+29426
No files matched your search
@@ -0,0 +1,6 @@
|
||||
AGENTCOMPANY_UPDATE_URL=
|
||||
AGENTCOMPANY_DISABLE_UPDATES=true
|
||||
CSC_LINK=
|
||||
CSC_KEY_PASSWORD=
|
||||
WIN_CSC_LINK=
|
||||
WIN_CSC_KEY_PASSWORD=
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor and OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Local reference material
|
||||
oldreference/
|
||||
|
||||
# Package manager logs
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -0,0 +1,339 @@
|
||||
# AgentCompany Desktop
|
||||
|
||||
Cross-platform Electron desktop control plane for local-first AI agent companies.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Electron 39+, Node 22+ (node:sqlite built-in)
|
||||
- **Renderer**: React 19, Tailwind CSS v4, Framer Motion, Phosphor Icons
|
||||
- **Fonts**: System stack (-apple-system, Segoe UI, etc.) for body; JetBrains Mono Variable for monospace
|
||||
- **State**: useState + IPC full-snapshot refresh pattern
|
||||
- **Build**: electron-vite, electron-builder (DMG/NSIS/AppImage)
|
||||
- **Validation**: Zod on both IPC sides (main + renderer)
|
||||
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
|
||||
Technical operators (solo devs, AI-native founders, engineering leads) who run multiple AI coding agents simultaneously via CLI tools like Codex, Claude Code, Kimi CLI, Gemini CLI, and OpenCode. They are power users who think in terms of organizations, budgets, and orchestration - not chat windows. Their context is a focused workstation where this app is the primary command surface for their AI workforce.
|
||||
|
||||
### Brand Personality
|
||||
|
||||
**Warm, Approachable, Clean** - like Notion meets Raycast. Professional but never cold. The interface should feel like a well-organized workspace run by someone who cares about craft, not a sterile enterprise dashboard. Friendly to newcomers but capable enough for power users.
|
||||
|
||||
### Emotional Goal
|
||||
|
||||
**Professional authority** - "I'm running a company." The operator should feel like a capable executive at their control surface. Every screen should reinforce that they are in command of their AI organization - seeing status, making decisions, taking action. Not anxious monitoring, but confident oversight.
|
||||
|
||||
### Aesthetic Direction
|
||||
|
||||
**Primary references**: Linear + Raycast
|
||||
- Clean sidebar with grouped navigation
|
||||
- Fast keyboard-driven navigation
|
||||
- Subtle, purposeful animations (not decorative)
|
||||
- Monochrome palette with one warm accent color
|
||||
- Information density without clutter
|
||||
|
||||
**Anti-references** (what this should NOT look like):
|
||||
- Cluttered enterprise dashboards (Jira, ServiceNow)
|
||||
- Overly playful / illustration-heavy consumer apps
|
||||
- Raw terminal UIs without visual hierarchy
|
||||
- Generic Bootstrap/Material admin templates
|
||||
|
||||
### Theme Strategy
|
||||
|
||||
**System-following** - automatically match OS light/dark preference. Both themes must be maintained. The current light palette is the foundation; dark mode tokens need to be added.
|
||||
|
||||
### Color System
|
||||
|
||||
| Token | Light | Purpose |
|
||||
|---|---|---|
|
||||
| `--bg` | `#fbfbfb` | App background |
|
||||
| `--panel` | `#ffffff` | Card/sidebar surfaces |
|
||||
| `--panel-soft` | `#f4f5f5` | Active/hover states |
|
||||
| `--text` | `#1a202c` | Primary text |
|
||||
| `--muted` | `#718096` | Secondary/caption text |
|
||||
| `--muted-strong` | `#4a5568` | Emphasized secondary text |
|
||||
| `--accent` | `#c06a3a` | Warm terracotta - primary action color (~4.6:1 contrast on white) |
|
||||
| `--accent-soft` | `#f7ece6` | Accent background tint |
|
||||
| `--danger` | `#e5484d` | Errors, destructive actions |
|
||||
| `--warn` | `#b7791f` | Warnings, budget alerts |
|
||||
| `--success` | `#2f855a` | Positive states, healthy budgets |
|
||||
| `--success-soft` | `#edf7f0` | Success background tint |
|
||||
| `--line` | `rgba(15,23,42,0.08)` | Subtle borders |
|
||||
| `--line-strong` | `rgba(15,23,42,0.14)` | Emphasized borders |
|
||||
|
||||
The warm terracotta accent (`#c06a3a`) is the signature color. It appears in active states, primary buttons, focus rings, and the icon fill for active sidebar items. Use sparingly - it should draw the eye to what matters.
|
||||
|
||||
### Typography Scale
|
||||
|
||||
- Page titles: 31px, -0.03em tracking
|
||||
- Section headings: 18px semibold, -0.01em
|
||||
- Body/labels: 14px
|
||||
- Compact UI (sidebar, pills, badges): 13px, 0.005em tracking
|
||||
- Captions/meta: 12px
|
||||
- Micro labels (section headers, status pills): 10-11px uppercase, wide tracking (0.08-0.12em)
|
||||
- Monospace (logs, IDs, timestamps): JetBrains Mono, 11-12px
|
||||
|
||||
### Spacing & Radius
|
||||
|
||||
- Border radius: 6-8px for interactive elements, 10px for cards/panels
|
||||
- Sidebar width: 220px fixed
|
||||
- Content max-width: 1040px centered
|
||||
- Page padding: 40px horizontal, 32px vertical
|
||||
- Section spacing: 56px (space-y-14) between major sections
|
||||
|
||||
### Animation Principles
|
||||
|
||||
- All transitions use `cubic-bezier(0.16, 1, 0.3, 1)` (ease-out-expo feel)
|
||||
- Page transitions: 240ms, y-axis slide (12px in, -10px out)
|
||||
- Panel reveals: 220ms, height or y-axis
|
||||
- Hover/focus: 150ms via Tailwind `duration-150`
|
||||
- Respect `prefers-reduced-motion` globally
|
||||
|
||||
### Icon System
|
||||
|
||||
Phosphor Icons (react), weight `regular` by default, `fill` for active/selected states. Standard size 16-18px. Active sidebar icons use accent color.
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Hierarchy before density** - Use typography scale, spacing, and color to create clear visual hierarchy. Never rely on borders or backgrounds alone to separate content.
|
||||
2. **Quiet until needed** - Default state is calm and minimal. Warnings, errors, and attention items earn their visual weight through color and placement, not through shouting.
|
||||
3. **One accent, used with intent** - The warm terracotta accent is reserved for: active navigation, primary actions, and focus states. Everything else is greyscale. If accent appears everywhere, it means nothing.
|
||||
4. **Native-feeling, not native-copying** - Feel at home on macOS (traffic lights, vibrancy cues, drag regions) but don't try to replicate AppKit. Same principle applies cross-platform.
|
||||
5. **Content over chrome** - Minimize UI framework noise. Headers, borders, and navigation are scaffolding - the user's agents, tasks, and runs are the content. Maximize the ratio of useful information to decorative structure.
|
||||
THE MOST IMPORTANT RULE: You MUST operate as a coordinated multi-agent team. All agents must strictly follow the defined roles, execution order, desktop application constraints, security boundaries, and delivery requirements. Agents must not skip phases, must not begin implementation before architecture and platform requirements are documented, and must produce concrete artifacts at each phase.
|
||||
|
||||
THIS TEAM EXISTS TO DESIGN, BUILD, REVIEW, OPTIMIZE, AND SHIP A PRODUCTION-GRADE CROSS-PLATFORM DESKTOP APPLICATION USING ELECTRON. ALL OUTPUTS MUST TARGET REAL ELECTRON APPLICATION WORKFLOWS, REAL MAIN PROCESS AND RENDERER PROCESS BOUNDARIES, REAL IPC SECURITY REQUIREMENTS, REAL PACKAGING REQUIREMENTS, AND REAL CROSS-PLATFORM CONSTRAINTS FOR macOS, WINDOWS, AND LINUX. DO NOT FALL BACK TO GENERIC WEB APP ADVICE. DO NOT TREAT THE DESKTOP APP AS A NORMAL WEBSITE. DEFAULT TO A SECURE ELECTRON ARCHITECTURE WITH STRICT SEPARATION BETWEEN MAIN, PRELOAD, AND RENDERER.
|
||||
|
||||
GLOBAL EXECUTION PIPELINE (MANDATORY ORDER)
|
||||
|
||||
Phase 1: ProjectManager reviews the PRD, defines scope, and establishes cross-platform desktop milestones.
|
||||
Phase 2: ResearchAnalyst validates Electron capabilities, desktop UX expectations, packaging requirements, OS integration constraints, and security requirements.
|
||||
Phase 3: TechnicalImplementationResearcher determines architecture, IPC model, state model, storage strategy, updater strategy, and performance approach.
|
||||
Phase 4: EnterpriseArchitect validates standards, module boundaries, process separation, file structure, naming rules, and desktop engineering constraints.
|
||||
Phase 5: FrontendDesigner converts product intent or provided design references into a desktop-native design system and screen structure suitable for Electron.
|
||||
Phase 6: FrontendDeveloper implements the renderer application using the chosen frontend stack with desktop-aware UX.
|
||||
Phase 7: BackendDeveloper implements the Electron main process, preload bridge, IPC layer, OS integrations, storage boundaries, and background services.
|
||||
Phase 8: BuildVerificationEngineer validates build integrity, IPC behavior, packaging, signing configuration, updater flow, and cross-platform runtime correctness.
|
||||
Phase 9: CodeReviewer optimizes correctness, warnings, performance, memory behavior, startup time, renderer responsiveness, and bundle size.
|
||||
Phase 10: SecurityAuditor performs final audit for IPC safety, preload exposure, file system permissions, secrets handling, updater safety, and release hygiene.
|
||||
Phase 11: ProjectManager validates final integration completeness, release readiness, and milestone closure.
|
||||
|
||||
Agents must never operate outside this pipeline. Implementation must not begin until scope, architecture, process boundaries, platform requirements, and design constraints are documented.
|
||||
|
||||
PROJECT OBJECTIVE
|
||||
|
||||
The goal is to produce a production-grade cross-platform desktop application using Electron with clear separation between main process, preload layer, and renderer process, strong IPC discipline, secure OS integration, maintainable architecture, stable packaging, and release-ready behavior across macOS, Windows, and Linux. The application must feel like a real desktop product rather than a website placed inside a desktop shell. Window behavior, menus, keyboard shortcuts, file dialogs, notifications, clipboard interactions, drag and drop, background tasks, persistence, and updater behavior must all be designed as desktop concerns.
|
||||
|
||||
PROJECT COMPLETION CRITERIA
|
||||
|
||||
The project is complete only when the application builds successfully for supported target platforms, type checking passes, linting passes, unit and integration tests for critical flows pass, renderer and main process communication works correctly, preload exposure is minimal and verified, packaging is valid, signing and notarization requirements are documented where relevant, auto-update strategy is defined if required, crashes and obvious runtime exceptions are eliminated, and no critical security or stability issues remain.
|
||||
|
||||
SUPPORTED PLATFORM TARGET
|
||||
|
||||
The system must explicitly define whether the app supports macOS only, Windows only, Linux only, or all three desktop targets. If the app is truly cross-platform, platform-specific differences in window chrome, file paths, menu behavior, notifications, keyboard modifiers, tray behavior, installer requirements, permissions, startup behavior, and signing or notarization requirements must be documented and handled intentionally. The application must not assume one platform’s conventions apply unchanged to another.
|
||||
|
||||
REPOSITORY STRUCTURE
|
||||
|
||||
The repository must follow a clear Electron-native structure.
|
||||
|
||||
repo
|
||||
apps
|
||||
desktop
|
||||
src
|
||||
main
|
||||
preload
|
||||
renderer
|
||||
components
|
||||
pages
|
||||
hooks
|
||||
state
|
||||
services
|
||||
ipc
|
||||
storage
|
||||
platform
|
||||
utils
|
||||
assets
|
||||
tests
|
||||
unit
|
||||
integration
|
||||
e2e
|
||||
scripts
|
||||
build
|
||||
docs
|
||||
electron-builder.yml
|
||||
package.json
|
||||
tsconfig.json
|
||||
.env.example
|
||||
|
||||
If the project is monorepo-based, workspace boundaries must remain explicit and maintainable. If the project is single-package, process boundaries must still remain explicit and not collapse into one mixed folder.
|
||||
|
||||
MILESTONE STRUCTURE
|
||||
|
||||
The ProjectManager must define milestones using native desktop release stages. At minimum these stages include architecture freeze, interactive prototype, internal alpha, cross-platform validation, signed beta build, release candidate, and production release readiness. Each milestone must specify feature scope, technical dependencies, blockers, packaging expectations, updater expectations, and acceptance criteria.
|
||||
|
||||
ELECTRON SECURITY FIRST RULE
|
||||
|
||||
Electron security is mandatory and not optional. The application must use contextIsolation enabled by default, nodeIntegration disabled in renderer by default, sandbox enabled where feasible, and a tightly scoped preload bridge. Renderer code must never access Node.js APIs directly unless explicitly allowed through audited preload exposure. Remote code execution risk, arbitrary file access, unsafe shell execution, and unrestricted IPC are forbidden.
|
||||
|
||||
ARCHITECTURE DECISION RULE
|
||||
|
||||
Architecture must be selected based on application complexity and process separation requirements. The team must evaluate and document why the chosen renderer framework and state strategy are appropriate for the project, including how the main process, preload layer, renderer, shared types, IPC contracts, storage layer, and background jobs interact. Architecture must be decided before full implementation begins. The system must avoid both monolithic god modules and unnecessary fragmentation.
|
||||
|
||||
PROCESS BOUNDARY RULE
|
||||
|
||||
The main process owns privileged access, OS integration, window lifecycle, menus, native dialogs, updater orchestration, and sensitive operations. The preload layer exposes a minimal, typed, audited bridge. The renderer handles UI, presentation logic, local interaction state, and calls only approved preload APIs. Business-critical privileged operations must never be executed directly in the renderer. Process boundaries must remain explicit, documented, and enforced by code structure.
|
||||
|
||||
IPC CONTRACT RULE
|
||||
|
||||
All IPC channels must be typed, versioned if needed, explicitly named, and documented. Unstructured string-based ad hoc IPC is forbidden. Every IPC operation must define request shape, response shape, error shape, privilege level, timeout expectations, and validation requirements. IPC handlers must validate inputs on the privileged side before performing any filesystem, network, shell, or OS-level action.
|
||||
|
||||
STATE MANAGEMENT RULE
|
||||
|
||||
State ownership must be explicit. UI-local state, app-global state, persisted state, async loading state, window state, and background task state must be clearly separated. The renderer must not become a dumping ground for all application logic. If shared state crosses process boundaries, that transfer must be deliberate and typed. The system must document where React Query, Zustand, Redux, Context API, or other state tools are used and why.
|
||||
|
||||
STORAGE RULE
|
||||
|
||||
Persistence must be chosen deliberately based on the app’s data model and offline behavior. The team must document whether persistence uses local files, SQLite, IndexedDB, key-value storage, or another desktop-safe mechanism. Sensitive data must never be stored insecurely when OS keychain or secure credential storage is available. Migration behavior, corruption handling, backup expectations, and recovery behavior must be documented. Storage locations must follow platform conventions.
|
||||
|
||||
NETWORKING RULE
|
||||
|
||||
If networking is required, the team must define whether requests originate in the main process or renderer and why. Timeout handling, retry behavior, cancellation, auth storage, offline support, background sync, streaming, and serialization rules must be documented. The app must never assume stable connectivity. Sensitive network requests should prefer the more controlled side of the architecture when security requires it.
|
||||
|
||||
DESKTOP UX RULE
|
||||
|
||||
All UI must be designed as desktop UI, not as transplanted mobile UI or plain website UI. The design must account for resizable windows, minimum window sizes, keyboard-first workflows, hover states, focus states, menus, context menus, drag and drop, clipboard flows, file system interactions, multi-window behavior if needed, command palette if relevant, and platform-specific modifier keys. Desktop affordances must be explicit and intentional.
|
||||
|
||||
DESIGN SYSTEM RULE
|
||||
|
||||
The FrontendDesigner must convert product intent or provided references into a desktop-compatible design system and view hierarchy. Typography scale, spacing scale, color tokens, surface hierarchy, focus states, hover states, loading states, empty states, error states, disabled states, and density behavior must be defined. If the application needs light mode and dark mode, that requirement must be specified early and implemented consistently. Icons, font loading, window framing behavior, and panel layouts must be documented.
|
||||
|
||||
ACCESSIBILITY RULE
|
||||
|
||||
Accessibility is mandatory. The application must support keyboard navigation, focus visibility, sufficient color contrast, readable sizing, reduced motion behavior where applicable, semantic roles where supported by the renderer framework, and accessible labeling for important interactions. Accessibility support must be treated as a product requirement rather than an optional polish pass.
|
||||
|
||||
OBSERVABILITY RULE
|
||||
|
||||
Diagnostic output must use structured logging with clear source ownership across main, preload, and renderer. Ad hoc console logging in final output is forbidden unless routed through a controlled logging strategy. Logs must not expose secrets, tokens, local private paths unless necessary, or personal user data. Crashes, failed IPC calls, updater issues, packaging issues, and important service failures must be observable without becoming noisy or unsafe.
|
||||
|
||||
TESTING RULE
|
||||
|
||||
The application must include testing appropriate to its scope. Core logic, IPC contracts, state transitions, storage boundaries, parsing, and critical workflows should have unit or integration tests. Important user flows should have end-to-end coverage where feasible. Cross-process behaviors must be tested deliberately, not assumed. Manual verification steps must be documented where automation is impractical.
|
||||
|
||||
PACKAGING AND RELEASE RULE
|
||||
|
||||
The system must produce release output suitable for native desktop distribution. Packaging expectations must be defined per platform, including macOS app bundle and signing or notarization expectations, Windows executable or installer expectations, and Linux packaging expectations if applicable. Auto-update strategy, icons, metadata, versioning, release channel separation, environment separation, and debug-versus-release behavior must be documented. Debug-only code must not remain in release output.
|
||||
|
||||
PERFORMANCE RULE
|
||||
|
||||
The application must feel responsive and desktop-grade. Startup time, idle memory usage, renderer responsiveness, large list performance, background task isolation, bundle size, unnecessary re-renders, preload overhead, IPC latency, and window creation performance must all be reviewed. Heavy CPU work must not block the renderer. Long-running privileged tasks must not freeze the UI. Performance-sensitive paths must be identified early rather than patched late.
|
||||
|
||||
PROJECTMANAGER — ELECTRON PRD REVIEWER AND COORDINATOR
|
||||
|
||||
The ProjectManager reviews the PRD and defines cross-platform desktop milestones, feature sequencing, dependency order, and delivery checkpoints. This role sets platform support requirements, packaging expectations, updater expectations, release stages, and coordination rules between design, renderer implementation, and main process implementation. The ProjectManager ensures that product scope remains realistic for Electron architecture and that milestone definitions are concrete enough for build execution.
|
||||
|
||||
RESEARCHANALYST — ELECTRON ECOSYSTEM AND DESKTOP RESEARCHER
|
||||
|
||||
The ResearchAnalyst researches current Electron capabilities, security recommendations, OS integration constraints, packaging requirements, secure storage options, updater options, platform-specific behavior, accessibility realities, and relevant open source implementation references. This role must distinguish between what Electron supports directly, what must be implemented through Node or native modules, and what remains risky or costly. The output must be concise, current, and implementation-relevant.
|
||||
|
||||
TECHNICALIMPLEMENTATIONRESEARCHER — ELECTRON DESKTOP ARCHITECTURE AND OPTIMIZATION RESEARCHER
|
||||
|
||||
The TechnicalImplementationResearcher studies the most efficient implementation path for the target features. This includes evaluating renderer framework choice, IPC modeling, preload API design, storage options, updater integration, worker usage, file watching, background processing, media handling, caching strategy, multi-window coordination, and memory behavior. The goal is to recommend a maintainable and performant architecture specifically for a desktop Electron application.
|
||||
|
||||
FRONTENDDESIGNER — DESKTOP ELECTRON UI AND UX DESIGN REVIEWER
|
||||
|
||||
The FrontendDesigner reviews provided design material and adapts it into a desktop-native design system and screen structure suitable for Electron. This role defines layout rules, spacing, typography, focus states, hover states, command surfaces, window-responsive behavior, toolbar behavior, panel layout, and desktop interaction flows. The design output must reflect actual desktop constraints including resizable windows, sidebars, toolbars, tabs if relevant, keyboard-first workflows, context menus, and dense information layouts where appropriate.
|
||||
|
||||
ENTERPRISEARCHITECT — ELECTRON CODE STANDARDS GUARDIAN
|
||||
|
||||
The EnterpriseArchitect enforces strict engineering rules. Main, preload, and renderer code must remain separated. No god modules are allowed. No direct Node access in renderer is allowed unless explicitly justified and audited. All IPC must be typed and validated. Secrets must never be hardcoded. Shell execution, filesystem access, and external process spawning must be isolated and strictly controlled. Reusable logic must not be duplicated. File naming must be descriptive and consistent. Shared types must be centralized. Public preload surface must be minimal. The architecture must remain secure and maintainable rather than imitating an unbounded full-trust desktop script.
|
||||
|
||||
FRONTENDDEVELOPER — ELECTRON RENDERER ENTERPRISE ENGINEER
|
||||
|
||||
The FrontendDeveloper implements the renderer application using the chosen frontend stack with strict type safety, maintainable component structure, predictable state management, desktop-aware interaction patterns, and minimal assumptions about privileged access. The renderer must consume only approved preload APIs or safe local browser APIs. The developer must implement loading states, error states, empty states, responsive window behavior, and keyboard-friendly interactions appropriate for desktop usage.
|
||||
|
||||
BACKENDDEVELOPER — ELECTRON MAIN PROCESS AND PRELOAD ENGINEER
|
||||
|
||||
The BackendDeveloper implements the Electron main process, preload bridge, IPC handlers, privileged integrations, storage access boundaries, menus, dialogs, notifications, background tasks, updater coordination, and platform integrations. This role owns sensitive operations and must validate all inputs before executing privileged actions. The preload layer must expose only minimal typed APIs. The implementation must be production-grade, secure, and aligned with the approved process model.
|
||||
|
||||
BUILDVERIFICATIONENGINEER — ELECTRON BUILD, PACKAGE, AND DEPLOYMENT SPECIALIST
|
||||
|
||||
The BuildVerificationEngineer validates local builds, packaging configuration, signing expectations, environment separation, preload bundling, IPC wiring, updater flow if applicable, installer integrity, and cross-platform runtime correctness. This role must confirm that production builds behave correctly, source maps and debug settings are handled appropriately, packaged apps launch correctly, and release artifacts are consistent with expectations.
|
||||
|
||||
CODEREVIEWER — ELECTRON BUILD OPTIMIZER AND PERFORMANCE TUNER
|
||||
|
||||
The CodeReviewer eliminates type errors, lint issues, avoidable bundle bloat, unnecessary renderer re-renders, untyped IPC paths, memory leaks, stale event listeners, preload overexposure, slow startup behavior, and privileged operations running on the wrong side of the architecture. This role reviews both correctness and runtime suitability for Electron desktop execution.
|
||||
|
||||
SECURITYAUDITOR — ELECTRON SECURITY AND RELEASE AUDITOR
|
||||
|
||||
The SecurityAuditor performs the final security and release review. Context isolation, sandbox policy, node integration policy, preload exposure, IPC validation, filesystem safety, secure credential storage, updater trust model, remote content policy, CSP strategy, shell execution boundaries, debug feature removal, and packaging hygiene must all be reviewed. The final audit must clearly state whether the application is safe and appropriate for internal alpha, public beta, or production release.
|
||||
|
||||
IMPLEMENTATION CONSTRAINTS
|
||||
|
||||
Use Electron as the desktop runtime. Maintain strict separation between main, preload, and renderer. Disable nodeIntegration in renderer by default. Enable contextIsolation by default. Use a minimal preload bridge. Validate all IPC inputs. Do not expose arbitrary filesystem, shell, or process APIs to renderer code. Do not hardcode secrets. Do not treat the desktop app as a normal website. Do not start implementation before milestone definition, architecture decision, process boundary review, platform requirement review, and design constraints are complete.
|
||||
|
||||
Carefully review the full `PRD.md` and then inspect every single line of the current Electron cross-platform desktop application codebase, including main process, renderer process, preload scripts, IPC architecture, window lifecycle, view structure, routing, UI flow, interaction logic, state management, local persistence, file system access, settings model, update flow, packaging configuration, platform integrations, security model, performance behavior, and all existing screens, panels, dialogs, and workflows. The current application is not usable as a real product: it lacks meaningful interaction, lacks coherent UX flow, lacks clear information hierarchy, lacks robust state handling, lacks polished desktop behavior, lacks complete feature implementation, lacks proper security boundaries, and lacks the depth required for a finished cross-platform Electron application. Treat the current build as an unusable prototype and fully redesign it into a complete, production-ready, polished desktop product.
|
||||
|
||||
Your task is to fully rebuild the application into a feature-complete, maintainable, performant, and production-ready cross-platform desktop experience. Do not do superficial edits. Do not only patch isolated bugs. Do not leave placeholder windows, placeholder views, placeholder copy, placeholder workflows, placeholder IPC hooks, incomplete screens, incomplete platform integrations, insecure bridges, mock logic, or “TODO” implementations. Redo the whole application in depth. This includes but is not limited to: full product flow, onboarding, window structure, multi-panel layout if relevant, navigation, keyboard and mouse interactions, drag-and-drop if relevant, empty states, loading states, error states, visual hierarchy, interaction feedback, text content, settings flow, persistence, async/background task coordination, IPC boundaries, file workflows, command palette if relevant, system integrations, update behavior, and overall product cohesion.
|
||||
|
||||
Build the app as if it must stand on its own as a real commercial desktop product, not a demo. Every screen must have purpose. Every control must have a clear function. Every interaction must produce visible and coherent feedback. Every major section must have readable information architecture, UX integrity, visual hierarchy, and product meaning. Add proper onboarding so a first-time user can understand what to do. Add product structure so the app has a reason to be used continuously. Add all missing interaction logic. Add polished UI presentation. Add complete user-facing text wherever necessary. Add proper settings, logs, history, file/workspace flow, shortcuts, menus, dialogs, and desktop-native behaviors expected from a serious Electron application.
|
||||
|
||||
You must work systematically and deeply, not lazily. First, audit the entire PRD and the full codebase. Identify all broken, missing, shallow, placeholder, insecure, or incoherent systems. Then redesign the application architecture where needed so the final result is maintainable and expandable. Then implement the full app end to end. Rewrite systems if the current ones are fundamentally bad. Replace weak screens if needed. Replace weak state flow if needed. Replace weak IPC design if needed. Rewrite copy, flows, and interaction logic if needed. Improve visual design, event handling, async coordination, security boundaries, state transitions, platform adaptation, and UX details so the result feels intentional and complete.
|
||||
|
||||
The rebuilt application must be truly designed for Electron and desktop-quality behavior, not just a web page wrapped in a shell. Respect the Electron architecture properly. Build a clean separation between main process, renderer process, and preload layer. Use secure IPC patterns. Minimize attack surface. Use context isolation, disable unsafe defaults, expose only explicit preload APIs, and avoid insecure renderer access to Node capabilities. Handle native menus, window controls, file dialogs, notifications, clipboard access, deep links, protocol handlers, and OS integrations properly where relevant. The final product must feel like a serious desktop application on macOS, Windows, and Linux, not a browser tab pretending to be native.
|
||||
|
||||
Prefer a production-grade Electron architecture and implementation style: strong module boundaries, explicit state ownership, clear separation between UI, domain logic, desktop integration, and persistence, predictable async execution, structured IPC contracts, robust error propagation, structured logging, testable units, maintainable packaging, and release-ready update strategy. If the current stack is weak or inconsistent, replace it with a maintainable Electron-native architecture.
|
||||
|
||||
If relevant, support:
|
||||
- Multi-window or multi-panel workflows.
|
||||
- Workspace/project-based usage model.
|
||||
- Local-first persistence where appropriate.
|
||||
- File system integration, drag-and-drop, import/export, and artifact management.
|
||||
- Keyboard-first desktop workflows and command palette behavior.
|
||||
- Run history, logs, diagnostics, and error surfacing.
|
||||
- Auto-update flow where appropriate.
|
||||
- Crash handling, recovery, and safe state restoration.
|
||||
- Cross-platform packaging and runtime stability.
|
||||
- Clear security hardening across main, preload, and renderer boundaries.
|
||||
|
||||
Deliverables must include:
|
||||
1. A full design and engineering audit of the current PRD and codebase, listing all major problems and why they make the application incomplete, insecure, or unusable.
|
||||
2. A complete redesign plan aligned with the PRD, including product flow, screen and window structure, navigation model, interaction model, IPC model, state model, persistence model, platform behavior, security model, and feature structure.
|
||||
3. A clear Electron architecture covering main process responsibilities, renderer responsibilities, preload API surface, IPC contracts, permissions, and security boundaries.
|
||||
4. Full implementation of all missing or broken systems in code.
|
||||
5. Fully functional screens, dialogs, panels, and workflows with complete interactions, guidance, and cross-platform desktop behavior.
|
||||
6. Complete user-facing copy and content where required, not placeholders.
|
||||
7. Full onboarding and product flow implementation from first launch to continued daily use.
|
||||
8. Proper animation and transition polish where technically appropriate for a desktop app.
|
||||
9. Application UX polish equivalent to game feel: responsiveness, feedback, clarity, smooth transitions, keyboard usability, pointer precision, and operational confidence.
|
||||
10. Removal of dead code, broken systems, placeholder logic, insecure patterns, and unused structures where appropriate.
|
||||
11. Packaging, updater, and runtime readiness for supported desktop platforms.
|
||||
12. A final fully usable Electron cross-platform desktop application, not a partial refactor.
|
||||
|
||||
Rules:
|
||||
- Do not be lazy.
|
||||
- Do not summarize only.
|
||||
- Do not stop at analysis.
|
||||
- Do not leave placeholder text such as “coming soon”, “TODO”, “insert copy here”, mock screens, fake workflows, or dummy interactions.
|
||||
- Do not preserve bad legacy code just for convenience.
|
||||
- Do not make minimal edits when a system needs to be rebuilt.
|
||||
- Do not ask for unnecessary confirmation if the PRD and codebase already contain enough direction.
|
||||
- Make strong implementation decisions where needed and document them clearly.
|
||||
- Maintain internal consistency across product logic, UI, state, IPC behavior, persistence, async behavior, security boundaries, and platform integration.
|
||||
- Keep the visual and interaction design cohesive with a polished, premium desktop product identity.
|
||||
- Respect Electron constraints and strengths, but do not use them as an excuse for weak UX, poor security, or incomplete functionality.
|
||||
- Do not rush or shortcut the work. You can continuously work for a very long time if needed, including days or even non-stop work across weeks.
|
||||
- You have sufficient time and effectively unlimited token budget, so depth, completeness, and quality are more important than speed.
|
||||
|
||||
Execution order:
|
||||
Phase 1: Read and understand the entire `PRD.md`.
|
||||
Phase 2: Review the entire codebase line by line and identify all structural, UX, engineering, IPC, security, platform, and packaging deficiencies.
|
||||
Phase 3: Produce a concrete rebuild plan.
|
||||
Phase 4: Redesign the desktop architecture across main process, renderer, preload, persistence, and platform integration.
|
||||
Phase 5: Implement the rebuilt app system by system.
|
||||
Phase 6: Connect all features into a complete and coherent Electron-based cross-platform desktop experience.
|
||||
Phase 7: Polish UX, state flow, async behavior, IPC contracts, security, platform behavior, keyboard workflows, animation, and packaging.
|
||||
Phase 8: Final verification: the application must be launchable, understandable, navigable, responsive, useful, stable, secure, and complete across supported desktop platforms.
|
||||
|
||||
Assume the goal is not “improve a prototype” but “finish the app for real.” Act like a principal Electron engineer, desktop architect, security engineer, product designer, UX designer, build/release engineer, and technical director combined. Produce real implementation, real workflows, and a complete result.
|
||||
@@ -0,0 +1,47 @@
|
||||
# AgentCompany
|
||||
|
||||
Cross-platform Electron desktop control plane for local-first AI agent companies.
|
||||
|
||||
AgentCompany is a standalone desktop application for managing multiple AI coding agents (Codex, Claude Code, Gemini CLI, OpenCode, Kimi Code) as an organization with goals, tasks, approvals, budgets, and audit trails.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test:unit
|
||||
pnpm test:integration
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Packaging
|
||||
|
||||
```sh
|
||||
pnpm build # Compile + package (DMG/NSIS/AppImage)
|
||||
pnpm build:dir # Compile + unpackaged directory
|
||||
```
|
||||
|
||||
- Supported targets: macOS, Windows, Linux
|
||||
- Packaging uses `electron-builder`
|
||||
- Local builds use ad-hoc signing (`CSC_IDENTITY_AUTO_DISCOVERY=false`)
|
||||
- Defaults to `--publish never`; override with `AGENTCOMPANY_ELECTRON_PUBLISH` env var
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
main/ # Electron main process, IPC, orchestration, database
|
||||
preload/ # Typed bridge (contextIsolation)
|
||||
renderer/ # React UI
|
||||
shared/ # Contracts, types shared across processes
|
||||
packages/
|
||||
adapter-utils/ # Shared adapter utilities
|
||||
adapter-*/ # Per-connector adapter packages
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="200" x2="824" y1="96" y2="928" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#111315"/>
|
||||
<stop offset="1" stop-color="#1b1f23"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0" cy="0" r="1"
|
||||
gradientTransform="translate(450 348) rotate(36) scale(800)" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#d97745" stop-opacity=".13"/>
|
||||
<stop offset="1" stop-color="#d97745" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="896" height="896" x="64" y="64" rx="224" fill="url(#bg)"/>
|
||||
<rect width="896" height="896" x="64" y="64" rx="224" fill="url(#glow)"/>
|
||||
<polygon points="490,200 534,200 578,800 298,800" fill="#d97745" fill-opacity=".94"/>
|
||||
<polygon points="490,200 534,200 726,800 446,800" fill="#d97745" fill-opacity=".94"/>
|
||||
<rect x="400" y="419" width="224" height="42" rx="12" fill="#ffebd7"/>
|
||||
<rect x="374" y="524" width="276" height="42" rx="12" fill="#ebb48c"/>
|
||||
<rect x="348" y="629" width="328" height="42" rx="12" fill="#be8c69"/>
|
||||
<circle cx="512" cy="198" r="20" fill="#ffebd7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,205 @@
|
||||
# Phase 1: ProjectManager Scope And Milestones
|
||||
|
||||
## Product Frame
|
||||
|
||||
- Product working name: `AgentCompany`
|
||||
- Product type: cross-platform Electron desktop application
|
||||
- Platform target: macOS, Windows, Linux
|
||||
- Primary mode: local-first single-operator control plane
|
||||
- Product goal: run a multi-agent company from one desktop application with explicit control over connectors, workspaces, tasks, runs, approvals, budgets, logs, and recovery
|
||||
|
||||
## Scope Source
|
||||
|
||||
- Existing Paperclip concept from `doc/GOAL.md`, `doc/PRODUCT.md`, `doc/SPEC-implementation.md`
|
||||
- Rebuild constraints and product direction from `/Users/zacharyzhang/Documents/Github/paperclip/PRD.md`
|
||||
|
||||
## Supported MVP Domains
|
||||
|
||||
- Local profile with backup and restore
|
||||
- Multi-company local data model
|
||||
- Connector registry with capability matrix
|
||||
- Agent registry and org tree
|
||||
- Goals, projects, tasks, lineage
|
||||
- Run orchestration with session persistence and live console
|
||||
- Approval queue and governance actions
|
||||
- Cost tracking and budget hard-stop behavior
|
||||
- Activity timeline and run diagnostics
|
||||
- Workspace and repository management
|
||||
- Secret management with OS-backed encryption where available
|
||||
- Packaging, signing configuration, updater wiring, and crash-safe recovery hooks
|
||||
|
||||
## Explicit Platform Decision
|
||||
|
||||
AgentCompany targets all three desktop platforms in the first implementation.
|
||||
|
||||
Platform-specific constraints that must be handled intentionally:
|
||||
|
||||
- macOS:
|
||||
- standard traffic-light window controls
|
||||
- `Cmd` modifiers in menus and shortcuts
|
||||
- notification permission behavior
|
||||
- notarization and Developer ID signing requirements
|
||||
- `safeStorage` may block while Keychain access is resolved
|
||||
- Windows:
|
||||
- NSIS installer target
|
||||
- `Ctrl` modifiers in menus and shortcuts
|
||||
- AppUserModelID and toast notification expectations
|
||||
- code signing certificate requirement for trusted distribution
|
||||
- Linux:
|
||||
- AppImage target with optional deb/rpm later
|
||||
- tray behavior differs by desktop environment
|
||||
- `safeStorage` backend may degrade to `basic_text`, so fallback vault behavior must stay explicit
|
||||
|
||||
## Delivery Milestones
|
||||
|
||||
### 1. Architecture Freeze
|
||||
|
||||
Feature scope:
|
||||
|
||||
- final process model
|
||||
- profile/storage layout
|
||||
- IPC contract surface
|
||||
- connector capability model
|
||||
- initial screen map
|
||||
|
||||
Dependencies:
|
||||
|
||||
- PRD audit
|
||||
- Electron security research
|
||||
- platform packaging research
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- architecture docs complete
|
||||
- privileged boundaries defined
|
||||
- no renderer-to-Node direct access
|
||||
|
||||
### 2. Interactive Prototype
|
||||
|
||||
Feature scope:
|
||||
|
||||
- shell window
|
||||
- top nav + sidebar + utility drawer
|
||||
- onboarding flow
|
||||
- fake local data rendered from real repositories/services
|
||||
|
||||
Dependencies:
|
||||
|
||||
- design system
|
||||
- typed preload bridge
|
||||
- renderer route skeleton
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- navigable desktop shell
|
||||
- keyboard navigation baseline
|
||||
- console drawer and inspector patterns established
|
||||
|
||||
### 3. Internal Alpha
|
||||
|
||||
Feature scope:
|
||||
|
||||
- local SQLite profile
|
||||
- connectors registry and environment checks
|
||||
- agents/tasks/approvals/workspaces CRUD
|
||||
- utility-process orchestrator
|
||||
- real run launch for supported local connectors
|
||||
|
||||
Dependencies:
|
||||
|
||||
- repository layer
|
||||
- connector host
|
||||
- activity logging
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- launch first task run from onboarding
|
||||
- view live logs
|
||||
- persist app state across restart
|
||||
|
||||
### 4. Cross-Platform Validation
|
||||
|
||||
Feature scope:
|
||||
|
||||
- window/menu differences
|
||||
- path handling
|
||||
- notifications
|
||||
- backup/restore
|
||||
- crash recovery path
|
||||
|
||||
Dependencies:
|
||||
|
||||
- packaged test builds
|
||||
- platform QA checklist
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- app boots and navigates on macOS, Windows, Linux
|
||||
- core workflows verified on each target
|
||||
|
||||
### 5. Signed Beta Build
|
||||
|
||||
Feature scope:
|
||||
|
||||
- electron-builder config
|
||||
- signing env handling
|
||||
- updater channel wiring
|
||||
- production logging paths
|
||||
|
||||
Dependencies:
|
||||
|
||||
- build pipeline
|
||||
- signing assets
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- signed distributables generated
|
||||
- updater checks optional and disableable
|
||||
- release metadata generated
|
||||
|
||||
### 6. Release Candidate
|
||||
|
||||
Feature scope:
|
||||
|
||||
- performance pass
|
||||
- memory/resource leak pass
|
||||
- e2e coverage for critical flows
|
||||
- security audit fixes
|
||||
|
||||
Dependencies:
|
||||
|
||||
- alpha feedback
|
||||
- perf instrumentation
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- no known critical security issue
|
||||
- no crash in scripted happy path flows
|
||||
- startup and live console remain responsive under load
|
||||
|
||||
### 7. Production Release Readiness
|
||||
|
||||
Feature scope:
|
||||
|
||||
- release checklist
|
||||
- support docs
|
||||
- backup guidance
|
||||
- known limitations register
|
||||
|
||||
Dependencies:
|
||||
|
||||
- verification results
|
||||
- signing and packaging validation
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- typecheck/build/tests pass or deviations are explicitly recorded
|
||||
- security and packaging docs complete
|
||||
- final manual QA checklist complete
|
||||
|
||||
## Feature Sequencing Rules
|
||||
|
||||
- Connector execution begins only after IPC and orchestration boundaries are documented.
|
||||
- Renderer implementation begins only after screen structure and state ownership are defined.
|
||||
- Packaging config begins early, but signing and updater activation stay behind environment gating.
|
||||
- Kimi support is tracked explicitly. If its runtime contract remains lower capability than other first-class connectors, the UI must declare the downgrade rather than hiding it.
|
||||
@@ -0,0 +1,162 @@
|
||||
# Phase 1: Current Paperclip Audit
|
||||
|
||||
## Audit Boundary
|
||||
|
||||
This audit compares the current Paperclip repository against the target state defined in `PRD.md`: a secure Electron desktop application with strict `main` / `preload` / `renderer` separation, local-first orchestration, packaging, and OS-integrated workflows.
|
||||
|
||||
## Severity 0: Blocking Architectural Mismatch
|
||||
|
||||
### 1. No Electron process model exists
|
||||
|
||||
- Current repo contains no `BrowserWindow`, `preload`, `contextBridge`, `ipcMain`, `ipcRenderer`, `utilityProcess`, or packaging configuration.
|
||||
- Evidence:
|
||||
- root search for Electron APIs returns no application implementation
|
||||
- [server/src/app.ts](/Users/zacharyzhang/Documents/Github/paperclip/server/src/app.ts)
|
||||
- [ui/src/App.tsx](/Users/zacharyzhang/Documents/Github/paperclip/ui/src/App.tsx)
|
||||
|
||||
Impact:
|
||||
|
||||
- There is no privileged boundary for filesystem, keychain, window lifecycle, native dialogs, menus, notifications, updater, or desktop protocol handling.
|
||||
- The current product cannot be hardened into a desktop app by shell-wrapping the existing UI.
|
||||
|
||||
### 2. Orchestration is server-centric, not desktop-local
|
||||
|
||||
- Heartbeat execution, run tracking, workspace realization, and activity propagation live in the server process.
|
||||
- Evidence:
|
||||
- [server/src/services/heartbeat.ts](/Users/zacharyzhang/Documents/Github/paperclip/server/src/services/heartbeat.ts)
|
||||
- [server/src/services/workspace-runtime.ts](/Users/zacharyzhang/Documents/Github/paperclip/server/src/services/workspace-runtime.ts)
|
||||
|
||||
Impact:
|
||||
|
||||
- The current implementation assumes a long-lived backend service instead of a desktop-owned local orchestration core.
|
||||
- Crash semantics, app restart recovery, and foreground/background coordination are not modeled as desktop concerns.
|
||||
|
||||
### 3. The product shell is a routed web board, not a desktop console
|
||||
|
||||
- The top-level UI is route-driven, page-centric, and optimized for browser navigation rather than split-pane desktop operations.
|
||||
- Evidence:
|
||||
- [ui/src/App.tsx](/Users/zacharyzhang/Documents/Github/paperclip/ui/src/App.tsx)
|
||||
- [ui/src/components/Layout.tsx](/Users/zacharyzhang/Documents/Github/paperclip/ui/src/components/Layout.tsx)
|
||||
|
||||
Impact:
|
||||
|
||||
- No desktop-native command surfaces for file dialogs, tray, menu actions, run console docking, workspace reveal, or keyboard-first operations.
|
||||
- Core runtime objects such as connector health, workspace locks, session continuity, and log streams are not first-order interaction primitives.
|
||||
|
||||
## Severity 1: Major Product And UX Gaps
|
||||
|
||||
### 4. Onboarding is web-form oriented and does not establish a desktop local profile
|
||||
|
||||
- Existing onboarding creates company, CEO, and task records through API calls, but it does not create a local profile boundary, filesystem roots, workspace permissions, or connector health setup.
|
||||
- Evidence:
|
||||
- [ui/src/components/OnboardingWizard.tsx](/Users/zacharyzhang/Documents/Github/paperclip/ui/src/components/OnboardingWizard.tsx)
|
||||
|
||||
Impact:
|
||||
|
||||
- First launch does not establish the desktop objects required by the PRD: local profile, workspace selection, connector detection, secret storage, and first-run orchestration.
|
||||
|
||||
### 5. Connector differences are hidden inside adapter packages instead of surfaced as product-level capability states
|
||||
|
||||
- Adapters exist, but the UI treats them mainly as form configuration.
|
||||
- Evidence:
|
||||
- [server/src/adapters/registry.ts](/Users/zacharyzhang/Documents/Github/paperclip/server/src/adapters/registry.ts)
|
||||
- [ui/src/adapters/index.ts](/Users/zacharyzhang/Documents/Github/paperclip/ui/src/adapters/index.ts)
|
||||
|
||||
Impact:
|
||||
|
||||
- Users cannot reason about installation status, auth status, session resume support, structured transcript support, graceful cancel support, or unattributed cost behavior at the product level.
|
||||
|
||||
### 6. Workspace and repo context are present in backend logic but weak in the product hierarchy
|
||||
|
||||
- Workspace realization and worktree logic already exist, but they are not promoted to a central desktop experience.
|
||||
- Evidence:
|
||||
- [server/src/services/workspace-runtime.ts](/Users/zacharyzhang/Documents/Github/paperclip/server/src/services/workspace-runtime.ts)
|
||||
- [doc/DEVELOPING.md](/Users/zacharyzhang/Documents/Github/paperclip/doc/DEVELOPING.md)
|
||||
|
||||
Impact:
|
||||
|
||||
- The codebase contains useful execution semantics, but the current UI still behaves like a CRUD admin console with runtime details buried behind secondary screens.
|
||||
|
||||
### 7. Run console and diagnostics are not treated as a persistent operational surface
|
||||
|
||||
- Logs and transcript UX exist, but not as a docked desktop utility layer with search, filtering, and background status awareness.
|
||||
- Evidence:
|
||||
- [ui/src/components/transcript/RunTranscriptView.tsx](/Users/zacharyzhang/Documents/Github/paperclip/ui/src/components/transcript/RunTranscriptView.tsx)
|
||||
- [ui/src/pages/RunTranscriptUxLab.tsx](/Users/zacharyzhang/Documents/Github/paperclip/ui/src/pages/RunTranscriptUxLab.tsx)
|
||||
|
||||
Impact:
|
||||
|
||||
- Long-running desktop workflows need a utility drawer, live fan-out events, failure escalation, and state restoration, which the current web UX does not provide.
|
||||
|
||||
## Severity 2: Security And Desktop-Readiness Gaps
|
||||
|
||||
### 8. No preload boundary means there is no audited API surface for privileged actions
|
||||
|
||||
Impact:
|
||||
|
||||
- Desktop actions such as open folder, reveal artifact, backup profile, launch connector, store secret, or trigger updater would currently have no typed, validated boundary.
|
||||
|
||||
### 9. Secret handling is designed for server deployment, not OS keychain-first desktop storage
|
||||
|
||||
- Current repo includes secret versioning and local encrypted storage semantics.
|
||||
- Evidence:
|
||||
- [server/src/services/secrets.ts](/Users/zacharyzhang/Documents/Github/paperclip/server/src/services/secrets.ts)
|
||||
- [doc/DATABASE.md](/Users/zacharyzhang/Documents/Github/paperclip/doc/DATABASE.md)
|
||||
|
||||
Impact:
|
||||
|
||||
- The PRD requires OS-backed secure storage first. The current implementation does not model Electron `safeStorage`, keychain backend selection, or desktop fallback behavior.
|
||||
|
||||
### 10. Packaging, signing, and updater flows do not exist
|
||||
|
||||
Impact:
|
||||
|
||||
- There is no release artifact model for DMG/NSIS/AppImage, no signing config, no update metadata flow, and no environment gates for release versus dev behavior.
|
||||
|
||||
### 11. IPC sender validation, CSP, and custom protocol concerns are absent because there is no desktop surface yet
|
||||
|
||||
Impact:
|
||||
|
||||
- These are not bugs in the current web app, but they are missing mandatory controls for the target architecture.
|
||||
|
||||
## Severity 3: Maintainability Risks When Porting Directly
|
||||
|
||||
### 12. Existing routes and page layout would pull too much orchestration detail into the renderer
|
||||
|
||||
- The current UI is page-heavy and query-heavy.
|
||||
- Directly transplanting it into an Electron renderer would encourage business and privileged logic to leak upward.
|
||||
|
||||
### 13. Current shared contracts are REST-oriented
|
||||
|
||||
- Existing shared types target HTTP API payloads and server validation.
|
||||
- Evidence:
|
||||
- [packages/shared/src/api.ts](/Users/zacharyzhang/Documents/Github/paperclip/packages/shared/src/api.ts)
|
||||
- [packages/shared/src/types](/Users/zacharyzhang/Documents/Github/paperclip/packages/shared/src/types)
|
||||
|
||||
Impact:
|
||||
|
||||
- Desktop IPC contracts need operation-specific schemas, privilege labels, and request/response/error shapes rather than generic REST reuse.
|
||||
|
||||
### 14. Current database layer is PostgreSQL-first
|
||||
|
||||
- Evidence:
|
||||
- [packages/db/src/schema](/Users/zacharyzhang/Documents/Github/paperclip/packages/db/src/schema)
|
||||
- [doc/DATABASE.md](/Users/zacharyzhang/Documents/Github/paperclip/doc/DATABASE.md)
|
||||
|
||||
Impact:
|
||||
|
||||
- The desktop app needs an embedded local database with explicit migration and recovery semantics. The current schema is a strong domain reference, but not a direct persistence drop-in.
|
||||
|
||||
## Valuable Assets To Reuse
|
||||
|
||||
- Company-scoped domain language
|
||||
- Agent org tree semantics
|
||||
- Goal / project / issue lineage rules
|
||||
- Approval lifecycle semantics
|
||||
- Budget and cost event concepts
|
||||
- Existing local connector adapter patterns for Codex, Claude, Gemini, OpenCode, Cursor, and generic process/http modes
|
||||
- Workspace and runtime service orchestration ideas
|
||||
|
||||
## Rewrite Decision
|
||||
|
||||
AgentCompany should reuse domain concepts and selected adapter/runtime patterns, but it should not inherit the current app shell, server-centric topology, or browser-admin navigation model.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Phase 2: ResearchAnalyst Findings
|
||||
|
||||
## Sources
|
||||
|
||||
- Electron security checklist: https://www.electronjs.org/docs/latest/tutorial/security
|
||||
- Electron safeStorage API: https://www.electronjs.org/docs/latest/api/safe-storage
|
||||
- Electron utilityProcess API: https://www.electronjs.org/docs/latest/api/utility-process
|
||||
- electron-builder auto-update: https://www.electron.build/auto-update
|
||||
- electron-builder code signing: https://www.electron.build/code-signing
|
||||
- Electron releases schedule: https://releases.electronjs.org/schedule
|
||||
- Electron releases index: https://releases.electronjs.org/release
|
||||
- Kimi Code official product/docs entry:
|
||||
- https://www.kimi.com/code
|
||||
- https://www.kimi.com/code/docs/en/
|
||||
|
||||
## Electron Security Requirements
|
||||
|
||||
From current Electron guidance, AgentCompany must treat desktop privilege as a first-order security boundary:
|
||||
|
||||
- load only local packaged content in the main renderer
|
||||
- keep `nodeIntegration` disabled
|
||||
- keep `contextIsolation` enabled
|
||||
- keep renderer sandboxing enabled where feasible
|
||||
- expose a minimal preload API through `contextBridge`
|
||||
- validate IPC sender and payloads
|
||||
- restrict navigation and new window creation
|
||||
- avoid `file://` in production and prefer a custom protocol
|
||||
- define a restrictive CSP
|
||||
- never expose shell or filesystem primitives wholesale to renderer code
|
||||
|
||||
## Runtime Version Decision
|
||||
|
||||
- Initial architecture research targeted Electron `41.0.0`.
|
||||
- Build verification on March 12, 2026 found `better-sqlite3@12.6.2` failing native rebuild against Electron `41.0.0` / Node `24.14.0`.
|
||||
- The implementation baseline is therefore pinned to Electron `39.8.0` for now.
|
||||
|
||||
Why this downgrade is deliberate:
|
||||
|
||||
- Electron `39.8.0` is still a stable release line as of March 12, 2026.
|
||||
- It preserves the existing SQLite storage implementation without forcing a same-phase storage rewrite.
|
||||
- The downgrade is narrower in scope than replacing persistence with a different driver while the product shell, IPC, and orchestration layers are still being completed.
|
||||
|
||||
Upgrade condition:
|
||||
|
||||
- Revisit Electron `41.x` after the SQLite layer is migrated to a compatible runtime path or the upstream native module supports the newer V8 ABI cleanly.
|
||||
|
||||
## Secret Storage Findings
|
||||
|
||||
- `safeStorage` is main-process only and uses OS-backed cryptography semantics.
|
||||
- macOS: keychain-backed, app-specific protection
|
||||
- Windows: DPAPI-backed, user-scope protection
|
||||
- Linux: backend varies; it can degrade to `basic_text`
|
||||
|
||||
Product implication:
|
||||
|
||||
- AgentCompany should use `safeStorage` when encryption is available.
|
||||
- On Linux, the app must surface the selected backend and explicitly fall back to an app-local encrypted vault when the selected backend is `basic_text` or unavailable.
|
||||
|
||||
## Background Processing Findings
|
||||
|
||||
- `utilityProcess.fork()` provides a Chromium-managed child process with Node enabled and message ports available.
|
||||
- It is appropriate for orchestration work that should not block the renderer and should stay separate from window lifecycle concerns.
|
||||
|
||||
Product implication:
|
||||
|
||||
- Use a utility process for the orchestration core, run queue, workspace locks, and connector process supervision.
|
||||
- Keep UI projection in renderer, privileged OS coordination in main, and long-running execution in the utility process.
|
||||
|
||||
## Packaging And Updater Findings
|
||||
|
||||
- electron-builder supports cross-platform packaging with:
|
||||
- macOS: `dmg` plus `zip`
|
||||
- Windows: `nsis`
|
||||
- Linux: `AppImage` and optional deb/rpm variants
|
||||
- Auto-update via `electron-updater` relies on generated metadata files such as `latest.yml`.
|
||||
- macOS auto-update requires a signed app.
|
||||
- Windows signing is also supported and should be treated as a release requirement.
|
||||
|
||||
Product implication:
|
||||
|
||||
- Package targets should default to DMG/ZIP, NSIS, and AppImage.
|
||||
- Update checks must be opt-in/disableable by user setting, as required by the PRD.
|
||||
- Signing configuration should be environment-driven and absent values must not block local development.
|
||||
|
||||
## Desktop UX Research Conclusions
|
||||
|
||||
- Desktop-first operators need split-pane information density, not a marketing-site layout.
|
||||
- Keyboard navigation, command surfaces, and context-preserving utility drawers matter more than route purity.
|
||||
- File reveal, open folder, drag/drop, and copy path are standard desktop affordances and should be first-class actions.
|
||||
|
||||
## Connector Research Conclusions
|
||||
|
||||
- The current Paperclip repo already contains strong local connector patterns for Codex, Claude Code, Gemini CLI, and OpenCode.
|
||||
- Kimi Code is a real official CLI offering, but its desktop integration contract requires explicit treatment instead of assuming parity with the existing adapters.
|
||||
|
||||
Implementation decision:
|
||||
|
||||
- Reuse the established adapter contract ideas from current Paperclip for supported local connectors.
|
||||
- Represent advanced capability differences explicitly in the connector registry and task/run UX.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Phase 3: TechnicalImplementationResearcher Decisions
|
||||
|
||||
## Architecture Choice
|
||||
|
||||
- Runtime: Electron `39.8.0`
|
||||
- Renderer: React + Vite + TypeScript
|
||||
- UI motion: Framer Motion, used selectively for layout transitions, state feedback, and utility-drawer choreography
|
||||
- Data: SQLite with WAL mode and a single-writer orchestration queue
|
||||
- Orchestration: Electron utility process
|
||||
- Packaging: electron-builder
|
||||
- Updates: `electron-updater`, gated by app settings and environment
|
||||
|
||||
## Why This Stack
|
||||
|
||||
- Electron is required by the project constraints and matches the need for local CLI/runtime orchestration.
|
||||
- React is already aligned with repo knowledge and keeps renderer iteration fast.
|
||||
- SQLite with WAL provides embedded local durability and low setup overhead, matching the PRD requirement to start without external database installation.
|
||||
- A utility process avoids freezing the renderer during process supervision, lock management, or log ingestion.
|
||||
|
||||
Version note:
|
||||
|
||||
- Electron `41.0.0` was evaluated first, but the shipping baseline moved to `39.8.0` because the current SQLite native dependency did not rebuild cleanly on the newer runtime during packaging verification.
|
||||
|
||||
## State Ownership
|
||||
|
||||
### Renderer-only state
|
||||
|
||||
- split-pane layout and inspector visibility
|
||||
- filter/search state
|
||||
- command palette visibility
|
||||
- form drafts
|
||||
- optimistic mutation feedback
|
||||
|
||||
### Main-process privileged state
|
||||
|
||||
- windows
|
||||
- menus
|
||||
- file dialogs
|
||||
- notifications
|
||||
- backup/import/export paths
|
||||
- updater lifecycle
|
||||
- safeStorage encryption/decryption
|
||||
|
||||
### Utility-process orchestration state
|
||||
|
||||
- connector child process registry
|
||||
- run queue
|
||||
- workspace write locks
|
||||
- per-agent concurrency locks
|
||||
- session persistence and resume eligibility
|
||||
- unfinished run reconciliation
|
||||
|
||||
### Persisted local state
|
||||
|
||||
- companies
|
||||
- connectors and connector config documents
|
||||
- workspaces
|
||||
- agents
|
||||
- goals/projects/tasks
|
||||
- approvals and comments
|
||||
- runs, run events, cost events
|
||||
- activity log
|
||||
- app settings and window state
|
||||
|
||||
## IPC Model
|
||||
|
||||
IPC is split into three layers:
|
||||
|
||||
1. Renderer to preload:
|
||||
- typed methods only
|
||||
- no raw channel strings in UI components
|
||||
2. Preload to main:
|
||||
- schema-validated request/response handlers
|
||||
- sender verified in main
|
||||
3. Main to utility process:
|
||||
- internal command bus over message ports
|
||||
- used for orchestration, not for UI privilege elevation
|
||||
|
||||
## Storage Strategy
|
||||
|
||||
- Database file: `profile.sqlite` under app profile data directory
|
||||
- WAL enabled
|
||||
- Attachments/logs/artifacts stored as files, indexed in SQLite
|
||||
- Secrets stored via `safeStorage` when available; otherwise fallback encrypted vault plus explicit risk state on Linux
|
||||
- Backups stored as timestamped archives under `backups/`
|
||||
|
||||
## Performance Strategy
|
||||
|
||||
- Renderer subscribes to normalized event streams instead of raw connector protocol noise
|
||||
- Utility process owns long-running child processes
|
||||
- Log viewer uses chunked pagination and windowed rendering
|
||||
- Expensive derived summaries are computed in main/utility and sent as projections
|
||||
|
||||
## Connector Strategy
|
||||
|
||||
Connector contract layers:
|
||||
|
||||
- required:
|
||||
- detect installation
|
||||
- health/auth check
|
||||
- execute
|
||||
- normalize stdout/stderr/events
|
||||
- declare capability matrix
|
||||
- optional:
|
||||
- session resume
|
||||
- graceful cancel
|
||||
- model discovery
|
||||
- structured transcript
|
||||
- cost attribution
|
||||
|
||||
## Updater Strategy
|
||||
|
||||
- default channel: disabled unless configured
|
||||
- manual update check available from app menu and settings
|
||||
- before install:
|
||||
- write backup manifest
|
||||
- flush db/log handles
|
||||
- if signature validation fails:
|
||||
- reject install
|
||||
- record audit event
|
||||
|
||||
## Failure Model
|
||||
|
||||
- app restart scans unfinished runs
|
||||
- stale running records become `interrupted`
|
||||
- retryable runs expose resume/retry actions in UI
|
||||
- workspace lock conflicts enqueue runs as `queued`
|
||||
- connector health failures block new run dispatch
|
||||
@@ -0,0 +1,113 @@
|
||||
# Phase 4: EnterpriseArchitect Standards
|
||||
|
||||
## Top-Level Structure
|
||||
|
||||
```text
|
||||
AgentCompany/
|
||||
assets/
|
||||
build/
|
||||
docs/
|
||||
scripts/
|
||||
src/
|
||||
main/
|
||||
preload/
|
||||
renderer/
|
||||
shared/
|
||||
tests/
|
||||
unit/
|
||||
integration/
|
||||
e2e/
|
||||
electron-builder.yml
|
||||
package.json
|
||||
tsconfig.json
|
||||
.env.example
|
||||
```
|
||||
|
||||
## Process Boundaries
|
||||
|
||||
### Main process owns
|
||||
|
||||
- window lifecycle
|
||||
- app protocol registration
|
||||
- IPC gateway
|
||||
- menu construction
|
||||
- native dialogs
|
||||
- notifications
|
||||
- updater
|
||||
- backup/restore coordination
|
||||
- safeStorage and vault bridge
|
||||
|
||||
### Preload owns
|
||||
|
||||
- narrow typed API
|
||||
- event subscription bridge
|
||||
- no business logic
|
||||
- no dynamic API exposure
|
||||
|
||||
### Renderer owns
|
||||
|
||||
- UI composition
|
||||
- read-only projections
|
||||
- local interaction state
|
||||
- invoking approved preload methods
|
||||
|
||||
### Utility process owns
|
||||
|
||||
- run execution
|
||||
- connector process supervision
|
||||
- workspace and agent locking
|
||||
- timeout handling
|
||||
- normalized event emission
|
||||
|
||||
## Naming Rules
|
||||
|
||||
- Use descriptive file names by role: `connector-service.ts`, `run-controller.ts`, `profile-repository.ts`
|
||||
- Shared schemas live under `src/shared`
|
||||
- No renderer component may import from `src/main`
|
||||
- No preload module may import from `src/renderer`
|
||||
|
||||
## IPC Rules
|
||||
|
||||
- All channels are constants in `src/shared/contracts/ipc.ts`
|
||||
- Every handler validates input with Zod before executing
|
||||
- Response shape is always `{ ok, data, error }`
|
||||
- Errors use explicit codes, never arbitrary thrown text at the renderer boundary
|
||||
|
||||
## Security Rules
|
||||
|
||||
- `contextIsolation: true`
|
||||
- `sandbox: true` for renderer window
|
||||
- `nodeIntegration: false`
|
||||
- navigation locked to `app://` content
|
||||
- `window.open` denied unless explicitly whitelisted
|
||||
- shell execution restricted to orchestration/connector host
|
||||
- connector env injection uses allowlists, never raw `process.env`
|
||||
|
||||
## Resource Lifetime Rules
|
||||
|
||||
Following the memory-safety constraints for long-lived desktop processes:
|
||||
|
||||
- every event subscription returns a disposer
|
||||
- every spawned child process is registered and terminated on app shutdown
|
||||
- timers are stored centrally and cleared on teardown
|
||||
- database handles are singleton-owned and closed during quit/update install
|
||||
- utility-process channels are recreated deterministically after crash or restart
|
||||
|
||||
## Module Boundaries
|
||||
|
||||
- `src/shared`: pure types, schemas, ipc contracts, constants
|
||||
- `src/main/platform`: OS integration, menu, notifications, paths
|
||||
- `src/main/storage`: SQLite, migrations, repositories
|
||||
- `src/main/services`: profile, connectors, backups, updater, IPC composition
|
||||
- `src/main/orchestrator`: utility-process bootstrap and bridge
|
||||
- `src/renderer/pages`: route-level views
|
||||
- `src/renderer/components`: reusable UI primitives and panels
|
||||
- `src/renderer/state`: UI store, query clients, preferences
|
||||
|
||||
## Non-Negotiable Constraints
|
||||
|
||||
- No ad hoc IPC from components
|
||||
- No direct filesystem or shell access in renderer
|
||||
- No secrets in renderer state
|
||||
- No mixed god service containing windows, db, and orchestration together
|
||||
- No placeholder routes or dead menu entries
|
||||
@@ -0,0 +1,121 @@
|
||||
# Frontend Design System
|
||||
|
||||
## Product Tone
|
||||
|
||||
- Audience: technical operators running multiple AI agents simultaneously
|
||||
- Interaction style: dense, precise, keyboard-first
|
||||
- Motion style: restrained, purposeful, confidence-building
|
||||
- Visual identity: warm, approachable workspace — Linear meets Raycast, not a neon cyber UI
|
||||
|
||||
## Typography
|
||||
|
||||
- Body/UI family: system stack (`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`)
|
||||
- Monospace family: JetBrains Mono Variable (`font-family: "JetBrains Mono Variable", monospace`)
|
||||
- Scale:
|
||||
- Page titles: 31px, -0.03em tracking
|
||||
- Section headings: 18px semibold, -0.01em
|
||||
- Body/labels: 14px
|
||||
- Compact UI (sidebar, pills, badges): 13px, 0.005em tracking
|
||||
- Captions/meta: 12px
|
||||
- Micro labels (status pills, section headers): 10-11px uppercase, 0.08-0.12em tracking
|
||||
- Monospace (logs, IDs, timestamps): JetBrains Mono, 11-12px
|
||||
|
||||
## Color Tokens
|
||||
|
||||
### Light mode (`:root` default)
|
||||
|
||||
| Token | Value | Purpose |
|
||||
|---|---|---|
|
||||
| `--bg` | `#fbfbfb` | App background |
|
||||
| `--panel` | `#ffffff` | Card/sidebar surfaces |
|
||||
| `--panel-soft` | `#f4f5f5` | Active/hover states |
|
||||
| `--text` | `#1a202c` | Primary text |
|
||||
| `--muted` | `#718096` | Secondary/caption text |
|
||||
| `--muted-strong` | `#4a5568` | Emphasized secondary text |
|
||||
| `--accent` | `#c06a3a` | Warm terracotta — primary action color (~4.6:1 on white) |
|
||||
| `--accent-soft` | `#f7ece6` | Accent background tint |
|
||||
| `--danger` | `#e5484d` | Errors, destructive actions |
|
||||
| `--warn` | `#b7791f` | Warnings, budget alerts |
|
||||
| `--success` | `#2f855a` | Positive states, healthy budgets |
|
||||
| `--success-soft` | `#edf7f0` | Success background tint |
|
||||
| `--line` | `rgba(15,23,42,0.08)` | Subtle borders |
|
||||
| `--line-strong` | `rgba(15,23,42,0.14)` | Emphasized borders |
|
||||
|
||||
### Dark mode (`[data-theme="dark"]` and `prefers-color-scheme: dark` when `data-theme="system"`)
|
||||
|
||||
| Token | Value |
|
||||
|---|---|
|
||||
| `--bg` | `#111315` |
|
||||
| `--panel` | `#1b1f23` |
|
||||
| `--panel-soft` | `#232931` |
|
||||
| `--text` | `#f4f7fb` |
|
||||
| `--muted` | `#9aa7b5` |
|
||||
| `--accent` | `#ee8a58` |
|
||||
| `--accent-soft` | `rgba(238,138,88,0.14)` |
|
||||
|
||||
Dark mode is fully implemented via `data-theme` attribute on `:root`, with a `prefers-color-scheme` media query for the `"system"` setting.
|
||||
|
||||
## Accent Color
|
||||
|
||||
The warm terracotta accent (`#c06a3a` light / `#ee8a58` dark) is the signature color. Use it only for:
|
||||
- Active navigation items (icon fill + label)
|
||||
- Primary action buttons
|
||||
- Focus rings
|
||||
|
||||
Everything else is greyscale. Overuse dilutes the signal.
|
||||
|
||||
## Focus Ring
|
||||
|
||||
```css
|
||||
.focus-ring:focus-visible {
|
||||
box-shadow: 0 0 0 1px rgba(192,106,58,0.5), 0 0 0 4px rgba(192,106,58,0.12);
|
||||
border-color: rgba(192,106,58,0.35);
|
||||
}
|
||||
```
|
||||
|
||||
## Layout Model
|
||||
|
||||
- Left sidebar: 220px fixed, keyboard-navigable
|
||||
- Main content: max-width 1040px centered, 40px horizontal / 32px vertical padding
|
||||
- Section spacing: 56px between major sections
|
||||
|
||||
## Spacing & Radius
|
||||
|
||||
- Interactive controls: 6-8px border radius
|
||||
- Cards/panels: 10px border radius
|
||||
- Dividers: 1px `var(--line)` borders, no heavy card shadows
|
||||
|
||||
## Animation
|
||||
|
||||
All transitions use `cubic-bezier(0.16, 1, 0.3, 1)` (ease-out-expo feel).
|
||||
|
||||
| Context | Duration |
|
||||
|---|---|
|
||||
| Hover/focus | 150ms (`duration-150`) |
|
||||
| Page transitions | 240ms, y-axis slide (12px in, -10px out) |
|
||||
| Panel reveals | 220ms, height or y-axis |
|
||||
|
||||
Respect `prefers-reduced-motion` globally — durations collapse to `0.01ms` when set.
|
||||
|
||||
## Icon System
|
||||
|
||||
Phosphor Icons (`@phosphor-icons/react`), weight `regular` by default, `fill` for active/selected states. Standard size 16-18px. Active sidebar icons use `--accent` color.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Hierarchy before density** — Typography, spacing, and color create visual hierarchy; never rely on borders alone.
|
||||
2. **Quiet until needed** — Default state is calm and minimal. Warnings and errors earn their visual weight through color and placement.
|
||||
3. **One accent, used with intent** — Terracotta is reserved for active navigation, primary actions, and focus states.
|
||||
4. **Native-feeling, not native-copying** — macOS-aware (drag regions, traffic lights) but not AppKit imitation.
|
||||
5. **Content over chrome** — The user's agents, tasks, and runs are the content. Minimize decorative scaffolding.
|
||||
|
||||
## Required Screens
|
||||
|
||||
Overview · Connectors · Agents · Tasks · Task Detail · Approvals · Costs · Activity · Workspaces · Settings · Onboarding
|
||||
|
||||
## Desktop-Specific Behaviors
|
||||
|
||||
- `Cmd` (macOS) / `Ctrl` (Windows/Linux) for search, command palette, save, refresh
|
||||
- Context menus on list rows and workspace rows
|
||||
- Drag/drop for workspace import and artifact attachment
|
||||
- Reveal in Finder/Explorer for logs and artifacts
|
||||
@@ -0,0 +1,38 @@
|
||||
# Phase 6: FrontendDeveloper Renderer Implementation
|
||||
|
||||
## Stack
|
||||
|
||||
- React `19`
|
||||
- TypeScript
|
||||
- Vite / electron-vite
|
||||
- Framer Motion for shell transitions and utility drawer motion
|
||||
|
||||
## Renderer Responsibilities Implemented
|
||||
|
||||
- first-launch onboarding for company bootstrap and workspace selection
|
||||
- persistent desktop shell with section navigation
|
||||
- command palette toggle and keyboard-driven navigation hooks
|
||||
- connector registry with status, version, auth posture, and configuration surfaces
|
||||
- company, agent, goal, project, task, approval, cost, activity, workspace, and settings panels
|
||||
- run console utility drawer with live status and log retrieval
|
||||
- empty, loading, and error states for first-run and operational flows
|
||||
|
||||
## State Model
|
||||
|
||||
- snapshot-driven renderer state sourced from `window.agentCompany.loadSnapshot()`
|
||||
- local UI state for section selection, command palette visibility, console visibility, dialog drafts, and selection state
|
||||
- event subscription via `window.agentCompany.subscribe(...)` for domain changes and run updates
|
||||
|
||||
## Desktop-Native UX Decisions
|
||||
|
||||
- shell layout assumes resizable desktop windows, not mobile breakpoints
|
||||
- keyboard shortcuts are routed from the application menu into renderer event handling
|
||||
- workspace opening and log access are explicit desktop actions
|
||||
- drawer, inspector, and list/detail panels remain visible at desktop densities
|
||||
|
||||
## Key Files
|
||||
|
||||
- [App.tsx](/Users/zacharyzhang/Documents/Github/paperclip/AgentCompany/src/renderer/App.tsx)
|
||||
- [main.tsx](/Users/zacharyzhang/Documents/Github/paperclip/AgentCompany/src/renderer/main.tsx)
|
||||
- [styles.css](/Users/zacharyzhang/Documents/Github/paperclip/AgentCompany/src/renderer/styles.css)
|
||||
- [index.html](/Users/zacharyzhang/Documents/Github/paperclip/AgentCompany/src/renderer/index.html)
|
||||
@@ -0,0 +1,35 @@
|
||||
# Phase 7: BackendDeveloper Main Process And Preload Implementation
|
||||
|
||||
## Main Process Responsibilities Implemented
|
||||
|
||||
- single-instance lock with focus restoration on second launch
|
||||
- secure `BrowserWindow` creation with `contextIsolation: true`, `nodeIntegration: false`, and `sandbox: true`
|
||||
- custom `app://` protocol for packaged renderer content
|
||||
- application menu, accelerators, native dialogs, and notifications
|
||||
- typed IPC registration with sender validation and schema validation
|
||||
- local SQLite profile bootstrap, backup, restore, and settings persistence
|
||||
- utility-process orchestration worker for run execution and log/status fan-out
|
||||
- updater wiring via `electron-updater`, gated by settings and environment
|
||||
|
||||
## Preload Bridge
|
||||
|
||||
- single exposed surface: `window.agentCompany`
|
||||
- no raw Node.js or Electron APIs exposed to renderer
|
||||
- typed invoke helpers only
|
||||
- renderer event subscription returns unsubscribe function and hides raw Electron event objects
|
||||
|
||||
## Storage And Secrets
|
||||
|
||||
- `node:sqlite` database under profile directory
|
||||
- WAL enabled
|
||||
- logs, artifacts, and backups stored as profile files
|
||||
- secrets encrypted with Electron `safeStorage` when available, otherwise vault fallback
|
||||
|
||||
## Key Files
|
||||
|
||||
- [index.ts](/Users/zacharyzhang/Documents/Github/paperclip/AgentCompany/src/main/index.ts)
|
||||
- [database.ts](/Users/zacharyzhang/Documents/Github/paperclip/AgentCompany/src/main/database.ts)
|
||||
- [vault.ts](/Users/zacharyzhang/Documents/Github/paperclip/AgentCompany/src/main/vault.ts)
|
||||
- [worker.ts](/Users/zacharyzhang/Documents/Github/paperclip/AgentCompany/src/main/orchestrator/worker.ts)
|
||||
- [index.ts](/Users/zacharyzhang/Documents/Github/paperclip/AgentCompany/src/preload/index.ts)
|
||||
- [contracts.ts](/Users/zacharyzhang/Documents/Github/paperclip/AgentCompany/src/shared/contracts.ts)
|
||||
@@ -0,0 +1,41 @@
|
||||
# Phase 8: BuildVerificationEngineer Validation
|
||||
|
||||
## Commands Run
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/agent-company lint
|
||||
pnpm --filter @paperclipai/agent-company typecheck
|
||||
pnpm --filter @paperclipai/agent-company test:run
|
||||
pnpm --filter @paperclipai/agent-company build:dir
|
||||
```
|
||||
|
||||
## Results
|
||||
|
||||
- lint: passed (0 errors, 0 warnings)
|
||||
- typecheck: passed
|
||||
- unit tests: passed
|
||||
- integration tests: passed
|
||||
- Electron smoke E2E: passed
|
||||
- macOS directory packaging: passed
|
||||
|
||||
## Validation Refresh (2026-03-13)
|
||||
|
||||
- reran the full gate locally on macOS:
|
||||
- `pnpm lint`
|
||||
- `pnpm typecheck`
|
||||
- `pnpm test:run`
|
||||
- `pnpm build:dir`
|
||||
- all commands passed without blocking errors
|
||||
- package output again completed with ad-hoc macOS signing and no notarization credentials
|
||||
|
||||
## Packaging Notes
|
||||
|
||||
- `build:dir` now routes through [build-desktop.mjs](/Users/zacharyzhang/Documents/Github/paperclip/AgentCompany/scripts/build-desktop.mjs)
|
||||
- default local packaging disables automatic signing identity discovery unless explicitly overridden
|
||||
- macOS packaging completed with ad-hoc signing fallback and skipped notarization when notarization credentials were not configured
|
||||
- custom app icons are now provided through [electron-builder.yml](/Users/zacharyzhang/Documents/Github/paperclip/AgentCompany/electron-builder.yml) and [assets](/Users/zacharyzhang/Documents/Github/paperclip/AgentCompany/assets)
|
||||
|
||||
## Platform Verification Boundary
|
||||
|
||||
- verified on local macOS host
|
||||
- Windows and Linux packaging configuration exists but was not executed on this host
|
||||
@@ -0,0 +1,41 @@
|
||||
# Phase 9: Code Reviewer
|
||||
|
||||
## Correctness Fixes Applied
|
||||
|
||||
- removed `better-sqlite3` from the desktop runtime and replaced it with `node:sqlite`
|
||||
- fixed packaged runtime resolution for `electron-log/main.js`
|
||||
- bundled local adapter workspace packages into the main-process build instead of leaving
|
||||
broken runtime imports behind after packaging
|
||||
- hardened trusted-sender checks for IPC invocation
|
||||
- pinned connector commands to audited runtime defaults instead of accepting arbitrary renderer
|
||||
overrides
|
||||
- scoped secrets to companies and resolved secret bindings by `companyId`
|
||||
- validated worker messages at runtime instead of trusting unchecked casts
|
||||
- closed a test coverage gap where `*.test.ts` files were present but excluded from Vitest
|
||||
|
||||
## Runtime Review
|
||||
|
||||
- renderer responsiveness is acceptable for the current feature set, but continued extraction
|
||||
of shell sections into smaller components remains a maintenance priority
|
||||
- heavy agent execution stays out of the renderer through the utility process boundary
|
||||
- background database work is local and bounded; privileged tasks do not run directly in the
|
||||
renderer
|
||||
- critical run lifecycle writes now execute transactionally
|
||||
- lint baseline is now warning-free via `eslint.config.mjs` and `pnpm lint`
|
||||
|
||||
## Remaining Performance Risks
|
||||
|
||||
- the renderer bundle is still large enough that future work should split heavier sections and
|
||||
defer rarely used surfaces
|
||||
- cross-platform production profiling has not yet been run on Windows and Linux hardware
|
||||
|
||||
## Performance Snapshot (2026-03-13)
|
||||
|
||||
- renderer JS bundle (`out/renderer/assets/index-*.js`): 1,162,405 bytes (~1.11 MB)
|
||||
- renderer CSS bundle (`out/renderer/assets/index-*.css`): 43,344 bytes (~42.3 KB)
|
||||
- main bundle (`out/main/index.js`): ~142.9 KB
|
||||
- main connector chunk (`out/main/chunks/connectors-*.js`): ~146.2 KB
|
||||
- preload bundle (`out/preload/index.js`): ~137.7 KB
|
||||
|
||||
The primary optimization target remains renderer bundle splitting and lazy-loading of low-frequency
|
||||
surfaces.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Phase 10: Security Auditor
|
||||
|
||||
## Security Posture
|
||||
|
||||
The desktop app now follows the required Electron baseline:
|
||||
|
||||
- `contextIsolation: true`
|
||||
- `nodeIntegration: false`
|
||||
- `sandbox: true`
|
||||
- minimal preload bridge
|
||||
- no direct renderer access to filesystem, shell, or process APIs
|
||||
|
||||
## Verified Controls
|
||||
|
||||
- IPC channels are explicitly named and typed in `src/shared/contracts.ts`
|
||||
- privileged handlers validate payloads with Zod on the main-process side
|
||||
- IPC invocation is rejected for untrusted sender frames
|
||||
- `window.open` and `webview` attachment are denied
|
||||
- navigation outside approved content is blocked
|
||||
- packaged renderer content is served through a controlled `app://` protocol handler with path
|
||||
traversal protection
|
||||
- `openPath` is restricted to managed workspace, log, artifact, backup, and profile paths
|
||||
- connector execution commands are pinned to the audited connector binary instead of renderer
|
||||
overrides
|
||||
- secrets are company-scoped and secret binding resolution is company-aware
|
||||
- `safeStorage` is required for production secret writes; the insecure vault fallback is limited
|
||||
to explicit test environments
|
||||
- worker-to-main messages are validated at runtime before mutating run state
|
||||
- **TODO**: Electron binary fuse hardening is NOT yet configured. Before public release,
|
||||
add `@electron/fuses` to disable `runAsNode`, `NODE_OPTIONS` / inspect CLI argument injection,
|
||||
enforce app-from-ASAR loading, and enable ASAR integrity validation
|
||||
|
||||
## Release Hygiene
|
||||
|
||||
- local builds do not auto-discover signing identities unless explicitly configured
|
||||
- desktop packaging defaults to `--publish never` to prevent accidental channel publication from local/CI build jobs
|
||||
- updater configuration remains present but still requires a real trusted update endpoint before
|
||||
public distribution
|
||||
- notarization and production signing credentials are intentionally external to source control
|
||||
|
||||
## Open Release Conditions
|
||||
|
||||
The app is appropriate for internal alpha and signed beta preparation. Public production release
|
||||
still requires:
|
||||
|
||||
- real signing credentials per platform
|
||||
- notarization on macOS
|
||||
- trusted update hosting
|
||||
- platform-native validation for Windows and Linux installers
|
||||
|
||||
## Re-Verification Notes (2026-03-13)
|
||||
|
||||
- reran lint, typecheck, tests, and macOS directory packaging with no security regressions
|
||||
- static source review confirmed no renderer-side `eval` / `new Function` usage
|
||||
- privileged surface remains preload-bridged through typed IPC channels only
|
||||
- `child_process.spawn` usage stays in main process and is constrained to connector command checks
|
||||
@@ -0,0 +1,199 @@
|
||||
# Phase 11: ProjectManager Release Readiness
|
||||
|
||||
## Milestone Status
|
||||
|
||||
- architecture freeze: complete
|
||||
- interactive prototype: complete
|
||||
- internal alpha desktop shell: complete
|
||||
- macOS local packaging validation: complete
|
||||
- signed beta build: pending signing/notarization credentials and platform-host verification
|
||||
- release candidate: pending Windows and Linux package validation
|
||||
- production release readiness: pending platform signing completion and final release-channel credentials
|
||||
|
||||
## What Is Ready
|
||||
|
||||
- dedicated Electron-native desktop workspace under `AgentCompany`
|
||||
- explicit main / preload / renderer / shared separation
|
||||
- local-first profile, secret storage, orchestration worker, IPC contracts, and desktop shell
|
||||
- onboarding to first usable shell flow
|
||||
- packaging configuration for macOS, Windows, and Linux
|
||||
- warning-free local lint baseline across main/preload/renderer
|
||||
- validation gate rerun on 2026-03-13 passed (`lint`, `typecheck`, `test:run`, `build:dir`)
|
||||
- **TODO**: PR CI quality gate workflow (`.github/workflows/agent-company-pr-verify.yml`) has not been created yet
|
||||
- **TODO**: Electron production fuse hardening has not been configured yet (see doc 11)
|
||||
- packaging script now defaults to `--publish never` unless explicitly overridden (`AGENTCOMPANY_ELECTRON_PUBLISH` or explicit `--publish`)
|
||||
|
||||
## Release Ownership Boundaries
|
||||
|
||||
| Area | Primary owner | Backup owner | Scope boundary | Exit evidence |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Release gate decision | CEO / ProjectManager | Product lead | Approves go/no-go only after all gates are green; does not bypass security/platform sign-off | Signed release checklist + release tag decision |
|
||||
| Signing credentials | Security Auditor | Platform Release Engineer | Manages cert issuance/rotation and secret distribution; build operators never commit secrets | Secret inventory record + cert expiry check |
|
||||
| macOS signing + notarization | macOS Release Engineer | Security Auditor | Runs signed build on macOS host and validates notarization ticket | `spctl`, `codesign`, `stapler` evidence |
|
||||
| Windows signing + installer trust | Windows Release Engineer | Security Auditor | Runs NSIS build on Windows host and validates Authenticode chain | `Get-AuthenticodeSignature` evidence |
|
||||
| Linux package validation | Linux Release Engineer | QA lead | Builds and smoke-tests AppImage on Linux host; verifies runtime dependencies | AppImage launch and smoke checklist |
|
||||
| Updater endpoint trust | Security Auditor | SRE / Infra | Owns TLS/domain trust policy and update channel integrity checks | `latest*.yml` metadata + artifact hash verification |
|
||||
| Cross-platform functional smoke | QA lead | Platform engineers | Verifies startup/onboarding/task-run/approval flows on each target OS | Smoke run logs + screenshots |
|
||||
|
||||
## Release Checklist (Production Candidate)
|
||||
|
||||
- [ ] `pnpm install --frozen-lockfile` completed on release host
|
||||
- [ ] `pnpm lint` passed
|
||||
- [ ] `pnpm typecheck` passed
|
||||
- [ ] `pnpm test:run` passed
|
||||
- [ ] `pnpm build:dir` passed
|
||||
- [ ] signing environment variables are set from secret manager (not shell history files)
|
||||
- [ ] `AGENTCOMPANY_UPDATE_URL` points to production or staging trusted domain (not `example.invalid`)
|
||||
- [ ] updater checks are enabled for release validation (`AGENTCOMPANY_DISABLE_UPDATES=false`)
|
||||
- [ ] macOS signed build + notarization validation completed on macOS host
|
||||
- [ ] Windows signed installer validation completed on Windows host
|
||||
- [ ] Linux AppImage validation completed on Linux host
|
||||
- [ ] update metadata (`latest*.yml`) and published artifacts hash-match
|
||||
- [ ] secrets regression check completed (existing local secrets remain readable after upgrade)
|
||||
- [ ] local data migration test completed (old profile boots and migrates without data loss)
|
||||
- [ ] final go/no-go approved by CEO / ProjectManager
|
||||
|
||||
## Runbook
|
||||
|
||||
### 1. Preflight (all platforms)
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test:run
|
||||
pnpm build:dir
|
||||
```
|
||||
|
||||
Expected: all commands pass; no skipped failures are allowed unless explicitly waived in release notes.
|
||||
|
||||
### 2. Signing Material Readiness
|
||||
|
||||
Prepare these values in CI secret manager or local secure vault:
|
||||
|
||||
- `AGENTCOMPANY_UPDATE_URL`
|
||||
- `CSC_LINK`
|
||||
- `CSC_KEY_PASSWORD`
|
||||
- `WIN_CSC_LINK`
|
||||
- `WIN_CSC_KEY_PASSWORD`
|
||||
- macOS notarization credentials (`APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`) if using Apple ID flow
|
||||
|
||||
Sanity check before release build:
|
||||
|
||||
```sh
|
||||
env | rg "^(AGENTCOMPANY_UPDATE_URL|CSC_LINK|WIN_CSC_LINK|APPLE_ID|APPLE_TEAM_ID)="
|
||||
```
|
||||
|
||||
### 3. macOS Signed Build + Notarization (native macOS host)
|
||||
|
||||
1. Enable release-mode updater behavior and set trusted update URL.
|
||||
2. Build signed artifacts.
|
||||
3. Validate signature + notarization ticket.
|
||||
|
||||
```sh
|
||||
export AGENTCOMPANY_DISABLE_UPDATES=false
|
||||
export AGENTCOMPANY_UPDATE_URL="https://updates.<your-domain>/agentcompany/stable"
|
||||
export CSC_LINK="file://$HOME/.signing/agentcompany-mac.p12"
|
||||
export CSC_KEY_PASSWORD="<from-secret-manager>"
|
||||
export CSC_IDENTITY_AUTO_DISCOVERY=true
|
||||
export APPLE_ID="<apple-notarization-account>"
|
||||
export APPLE_APP_SPECIFIC_PASSWORD="<app-specific-password>"
|
||||
export APPLE_TEAM_ID="<team-id>"
|
||||
|
||||
pnpm build -- --mac dmg zip --publish never
|
||||
|
||||
spctl -a -vv "build/release/mac/AgentCompany.app"
|
||||
codesign -dv --verbose=4 "build/release/mac/AgentCompany.app" 2>&1 | rg "Authority=|TeamIdentifier="
|
||||
xcrun stapler validate "build/release"/*.dmg
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- For public macOS distribution, switch `mac.hardenedRuntime` to `true` with proper entitlements before final GA build.
|
||||
- Keep ad-hoc signing only for local development and internal unsigned testing.
|
||||
|
||||
### 4. Windows Signed Installer Validation (native Windows host)
|
||||
|
||||
```powershell
|
||||
$env:AGENTCOMPANY_DISABLE_UPDATES = "false"
|
||||
$env:AGENTCOMPANY_UPDATE_URL = "https://updates.<your-domain>/agentcompany/stable"
|
||||
$env:WIN_CSC_LINK = "C:\signing\agentcompany-win.pfx"
|
||||
$env:WIN_CSC_KEY_PASSWORD = "<from-secret-manager>"
|
||||
|
||||
pnpm build -- --win nsis --x64 --publish never
|
||||
|
||||
Get-ChildItem .\build\release\*.exe | Get-AuthenticodeSignature |
|
||||
Format-Table Path, Status, @{Name='Signer';Expression={$_.SignerCertificate.Subject}}
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `Status` is `Valid`
|
||||
- signer subject matches the intended publisher identity
|
||||
|
||||
### 5. Linux AppImage Validation (native Linux host)
|
||||
|
||||
```sh
|
||||
export AGENTCOMPANY_DISABLE_UPDATES=false
|
||||
export AGENTCOMPANY_UPDATE_URL="https://updates.<your-domain>/agentcompany/stable"
|
||||
|
||||
pnpm build -- --linux AppImage --x64 --publish never
|
||||
|
||||
chmod +x build/release/*.AppImage
|
||||
./build/release/*.AppImage --appimage-version
|
||||
```
|
||||
|
||||
Then run manual smoke validation on Linux:
|
||||
|
||||
- app launches and reaches main shell
|
||||
- company/workspace/agent CRUD works
|
||||
- task run + approval flow works
|
||||
- settings toggle for updater works
|
||||
|
||||
### 6. Updater Endpoint Trust Verification
|
||||
|
||||
1. Validate update metadata endpoint and schema.
|
||||
2. Verify published artifact hash against metadata.
|
||||
3. Validate app update check in runtime.
|
||||
|
||||
```sh
|
||||
curl -fsSL "$AGENTCOMPANY_UPDATE_URL/latest.yml" -o /tmp/latest.yml
|
||||
rg "^(version|path|sha512):" /tmp/latest.yml
|
||||
```
|
||||
|
||||
Hash verification (replace placeholders with actual artifact path/hash from metadata):
|
||||
|
||||
```sh
|
||||
node -e "const fs=require('fs');const crypto=require('crypto');const [file,expected]=process.argv.slice(1);const got=crypto.createHash('sha512').update(fs.readFileSync(file)).digest('base64');if(got!==expected){console.error('sha512 mismatch');process.exit(1)}console.log('sha512 ok');" \
|
||||
build/release/<artifact-file> \
|
||||
"<sha512-from-latest-yml>"
|
||||
```
|
||||
|
||||
Runtime verification:
|
||||
|
||||
- launch packaged app with `AGENTCOMPANY_DISABLE_UPDATES=false`
|
||||
- trigger `Check for updates` from Settings
|
||||
- confirm request goes only to trusted `AGENTCOMPANY_UPDATE_URL` domain
|
||||
- confirm unsigned/untrusted update payloads are rejected by platform signing checks
|
||||
|
||||
### 7. Windows/Linux Native-Host QA Gate
|
||||
|
||||
Required evidence per platform:
|
||||
|
||||
- startup success + no crash in first 5 minutes
|
||||
- create company + create workspace + create task + assign agent + run once
|
||||
- approve one pending approval item
|
||||
- inspect logs for uncaught errors
|
||||
- close/reopen app and confirm local data persistence
|
||||
|
||||
### 8. Release Close-Out
|
||||
|
||||
- publish artifacts and corresponding `latest*.yml` only after all platform owners sign off
|
||||
- attach evidence bundle (command output, screenshots, hash checks, signatures)
|
||||
- mark release decision in changelog/release note with approver and timestamp
|
||||
|
||||
## Outstanding Risks Before Public GA
|
||||
|
||||
- current config keeps `mac.hardenedRuntime: false`; must be enabled with release entitlements for notarized public mac builds
|
||||
- no trusted production updater endpoint is configured by default (`example.invalid` fallback)
|
||||
- Windows and Linux validation still requires native host execution each release cycle
|
||||
@@ -0,0 +1,34 @@
|
||||
appId: com.agentcompany.desktop
|
||||
productName: AgentCompany
|
||||
directories:
|
||||
buildResources: assets
|
||||
output: build/release
|
||||
files:
|
||||
- out/**
|
||||
- package.json
|
||||
asar: true
|
||||
icon: assets/icon.png
|
||||
mac:
|
||||
category: public.app-category.developer-tools
|
||||
icon: assets/icon.icns
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
# hardenedRuntime requires a real code signing identity.
|
||||
# For local/unsigned builds, keep it off. Enable when signing is configured.
|
||||
hardenedRuntime: false
|
||||
win:
|
||||
icon: assets/icon.ico
|
||||
target:
|
||||
- nsis
|
||||
linux:
|
||||
icon: assets/icon.png
|
||||
target:
|
||||
- AppImage
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
publish:
|
||||
provider: generic
|
||||
url: ${AGENTCOMPANY_UPDATE_URL:-https://updates.example.invalid/agentcompany}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { resolve } from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
||||
|
||||
const bundledMainDependencies = [
|
||||
"@agentcompany/adapter-claude-local",
|
||||
"@agentcompany/adapter-codex-local",
|
||||
"@agentcompany/adapter-gemini-local",
|
||||
"@agentcompany/adapter-opencode-local",
|
||||
"@agentcompany/adapter-utils",
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin({ exclude: bundledMainDependencies })],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@shared": resolve("src/shared"),
|
||||
"@main": resolve("src/main"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve("src/main/index.ts"),
|
||||
worker: resolve("src/main/orchestrator/worker.ts"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin({ exclude: ["zod"] })],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@shared": resolve("src/shared"),
|
||||
"@preload": resolve("src/preload"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve("src/preload/index.ts"),
|
||||
},
|
||||
output: {
|
||||
format: "cjs",
|
||||
entryFileNames: "[name].js",
|
||||
chunkFileNames: "chunks/[name]-[hash].js",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
"@shared": resolve("src/shared"),
|
||||
"@renderer": resolve("src/renderer"),
|
||||
},
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"out/**",
|
||||
"build/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
"test-results/**",
|
||||
"playwright-report/**",
|
||||
"coverage/**",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}", "scripts/**/*.{ts,mjs}", "packages/**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"react-hooks/refs": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["tests/**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.vitest,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,436 @@
|
||||
<html lang="en" vid="0"><head vid="1">
|
||||
<meta charset="UTF-8" vid="2">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
|
||||
<title vid="4">Dashboard</title>
|
||||
<style vid="5">
|
||||
:root {
|
||||
|
||||
--bg-base: #ffffff;
|
||||
--bg-sidebar: #ffffff;
|
||||
--bg-hover: #f4f5f5;
|
||||
--bg-element: #f4f5f5;
|
||||
--bg-card: #ffffff;
|
||||
|
||||
|
||||
--text-primary: #1a202c;
|
||||
--text-secondary: #718096;
|
||||
--text-tertiary: #a0aec0;
|
||||
|
||||
--border-faint: rgba(0, 0, 0, 0.05);
|
||||
--accent-red: #e5484d;
|
||||
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 16px;
|
||||
--space-4: 24px;
|
||||
--space-5: 32px;
|
||||
--space-6: 48px;
|
||||
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
|
||||
h1, h2, h3 {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: capitalize;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: var(--space-2);
|
||||
padding-left: var(--space-2);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.field-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background-color: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border-faint);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.content-inner {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
|
||||
.nav-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
transition: background-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background-color: var(--bg-hover);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item.danger {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-icon {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.danger .nav-icon {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--space-6);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background-color: var(--bg-element);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
.data-section {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.data-container {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
|
||||
.data-header {
|
||||
background-color: #fafafa;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.data-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-row {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
|
||||
.data-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.row-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-element);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.row-avatar.brand {
|
||||
background-color: #e57339;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.row-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.row-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background-color: var(--bg-base);
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
|
||||
svg {
|
||||
stroke-width: 1.5;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body vid="6">
|
||||
|
||||
<aside class="sidebar" vid="7">
|
||||
<div class="nav-group" vid="8">
|
||||
<div class="section-label" vid="9">Overview</div>
|
||||
<a href="#" class="nav-item active" vid="10">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" vid="11"><rect x="3" y="3" width="7" height="7" vid="12"></rect><rect x="14" y="3" width="7" height="7" vid="13"></rect><rect x="14" y="14" width="7" height="7" vid="14"></rect><rect x="3" y="14" width="7" height="7" vid="15"></rect></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="#" class="nav-item" vid="16">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" vid="17"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12" vid="18"></polyline></svg>
|
||||
Analytics
|
||||
</a>
|
||||
<a href="#" class="nav-item" vid="19">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" vid="20"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" vid="21"></path><circle cx="9" cy="7" r="4" vid="22"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87" vid="23"></path><path d="M16 3.13a4 4 0 0 1 0 7.75" vid="24"></path></svg>
|
||||
Audience
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-group" vid="25">
|
||||
<div class="section-label" vid="26">Management</div>
|
||||
<a href="#" class="nav-item" vid="27">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" vid="28"><rect x="3" y="4" width="18" height="18" rx="2" ry="2" vid="29"></rect><line x1="16" y1="2" x2="16" y2="6" vid="30"></line><line x1="8" y1="2" x2="8" y2="6" vid="31"></line><line x1="3" y1="10" x2="21" y2="10" vid="32"></line></svg>
|
||||
Campaigns
|
||||
</a>
|
||||
<a href="#" class="nav-item" vid="33">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" vid="34"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" vid="35"></path></svg>
|
||||
Assets
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-group" style="margin-top: auto;" vid="36">
|
||||
<a href="#" class="nav-item" vid="37">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" vid="38"><circle cx="12" cy="12" r="3" vid="39"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" vid="40"></path></svg>
|
||||
Settings
|
||||
</a>
|
||||
<a href="#" class="nav-item danger" vid="41">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" vid="42"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" vid="43"></path><polyline points="16 17 21 12 16 7" vid="44"></polyline><line x1="21" y1="12" x2="9" y2="12" vid="45"></line></svg>
|
||||
Log out
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content" vid="46">
|
||||
<div class="content-inner" vid="47">
|
||||
|
||||
<header class="page-header" vid="48">
|
||||
<h1 class="page-title" vid="49">Overview</h1>
|
||||
</header>
|
||||
|
||||
<section class="data-section" vid="50">
|
||||
<div class="field-label" vid="51">Current Performance</div>
|
||||
<div class="field-desc" vid="52">Metrics from the last 30 days.</div>
|
||||
|
||||
<div class="metrics-grid" vid="53">
|
||||
<div class="metric-card" vid="54">
|
||||
<span class="metric-label" vid="55">Total Views</span>
|
||||
<span class="metric-value" vid="56">124,500</span>
|
||||
</div>
|
||||
<div class="metric-card" vid="57">
|
||||
<span class="metric-label" vid="58">Engagement Rate</span>
|
||||
<span class="metric-value" vid="59">4.2%</span>
|
||||
</div>
|
||||
<div class="metric-card" vid="60">
|
||||
<span class="metric-label" vid="61">Active Users</span>
|
||||
<span class="metric-value" vid="62">8,192</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="data-section" vid="63">
|
||||
<div class="field-label" vid="64">Recent Activity</div>
|
||||
<div class="field-desc" vid="65">Latest actions across your workspace.</div>
|
||||
|
||||
<div class="data-container" vid="66">
|
||||
<div class="data-header" vid="67">
|
||||
<span vid="68">Activity Feed</span>
|
||||
<span style="color: var(--text-secondary); font-weight: normal;" vid="69">View all</span>
|
||||
</div>
|
||||
<div class="data-list" vid="70">
|
||||
|
||||
<div class="data-row" vid="71">
|
||||
<div class="row-content" vid="72">
|
||||
<div class="row-avatar brand" vid="73">J</div>
|
||||
<div class="row-text" vid="74">
|
||||
<span class="row-title" vid="75">John Smith updated Profile</span>
|
||||
<span class="row-subtitle" vid="76">2 hours ago • Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-button" vid="77">Review</button>
|
||||
</div>
|
||||
|
||||
<div class="data-row" vid="78">
|
||||
<div class="row-content" vid="79">
|
||||
<div class="row-avatar" vid="80">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" vid="81"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" vid="82"></path><polyline points="7 10 12 15 17 10" vid="83"></polyline><line x1="12" y1="15" x2="12" y2="3" vid="84"></line></svg>
|
||||
</div>
|
||||
<div class="row-text" vid="85">
|
||||
<span class="row-title" vid="86">Q3 Report Generated</span>
|
||||
<span class="row-subtitle" vid="87">Yesterday • System</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-button" vid="88">Download</button>
|
||||
</div>
|
||||
|
||||
<div class="data-row" vid="89">
|
||||
<div class="row-content" vid="90">
|
||||
<div class="row-avatar" vid="91">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" vid="92"><circle cx="12" cy="12" r="10" vid="93"></circle><polyline points="12 6 12 12 16 14" vid="94"></polyline></svg>
|
||||
</div>
|
||||
<div class="row-text" vid="95">
|
||||
<span class="row-title" vid="96">New integration synced</span>
|
||||
<span class="row-subtitle" vid="97">Oct 24 • Automations</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-button" vid="98">Manage</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
</body></html>
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "agent-company",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
|
||||
"author": "AgentCompany",
|
||||
"main": "out/main/index.js",
|
||||
"scripts": {
|
||||
"assets:generate": "python3 scripts/generate-icons.py",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build && node scripts/build-desktop.mjs",
|
||||
"build:dir": "electron-vite build && node scripts/build-desktop.mjs --dir",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:unit": "vitest run --config vitest.config.ts tests/unit",
|
||||
"test:integration": "vitest run --config vitest.config.ts tests/integration",
|
||||
"test:e2e": "playwright test -c tests/e2e/playwright.config.ts",
|
||||
"test:run": "pnpm test:unit && pnpm test:integration && pnpm test:e2e",
|
||||
"postinstall": "bash scripts/patch-electron-name.sh || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "5.2.8",
|
||||
"@agentcompany/adapter-claude-local": "workspace:*",
|
||||
"@agentcompany/adapter-codex-local": "workspace:*",
|
||||
"@agentcompany/adapter-gemini-local": "workspace:*",
|
||||
"@agentcompany/adapter-opencode-local": "workspace:*",
|
||||
"@agentcompany/adapter-utils": "workspace:*",
|
||||
"@phosphor-icons/react": "2.1.10",
|
||||
"clsx": "2.1.1",
|
||||
"electron-log": "5.4.3",
|
||||
"electron-updater": "6.8.3",
|
||||
"framer-motion": "12.36.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@eslint/js": "9.38.0",
|
||||
"electron": "39.8.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"electron-vite": "5.0.0",
|
||||
"eslint": "9.38.0",
|
||||
"eslint-plugin-react-hooks": "7.0.0",
|
||||
"eslint-plugin-react-refresh": "0.4.24",
|
||||
"globals": "16.4.0",
|
||||
"tailwindcss": "4.2.1",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.46.1",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# @paperclipai/adapter-claude-local
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.7
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.6
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.5
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.4
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.3
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.2
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.1
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@agentcompany/adapter-claude-local",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentcompany/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
function asErrorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return "";
|
||||
const obj = value as Record<string, unknown>;
|
||||
const message =
|
||||
(typeof obj.message === "string" && obj.message) ||
|
||||
(typeof obj.error === "string" && obj.error) ||
|
||||
(typeof obj.code === "string" && obj.code) ||
|
||||
"";
|
||||
if (message) return message;
|
||||
try {
|
||||
return JSON.stringify(obj);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function printClaudeStreamEvent(raw: string, debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||
|
||||
if (type === "system" && parsed.subtype === "init") {
|
||||
const model = typeof parsed.model === "string" ? parsed.model : "unknown";
|
||||
const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : "";
|
||||
console.log(pc.blue(`Claude initialized (model: ${model}${sessionId ? `, session: ${sessionId}` : ""})`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const message =
|
||||
typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
|
||||
? (parsed.message as Record<string, unknown>)
|
||||
: {};
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const blockRaw of content) {
|
||||
if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
|
||||
const block = blockRaw as Record<string, unknown>;
|
||||
const blockType = typeof block.type === "string" ? block.type : "";
|
||||
if (blockType === "text") {
|
||||
const text = typeof block.text === "string" ? block.text : "";
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
} else if (blockType === "tool_use") {
|
||||
const name = typeof block.name === "string" ? block.name : "unknown";
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
if (block.input !== undefined) {
|
||||
console.log(pc.gray(JSON.stringify(block.input, null, 2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage =
|
||||
typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)
|
||||
? (parsed.usage as Record<string, unknown>)
|
||||
: {};
|
||||
const input = Number(usage.input_tokens ?? 0);
|
||||
const output = Number(usage.output_tokens ?? 0);
|
||||
const cached = Number(usage.cache_read_input_tokens ?? 0);
|
||||
const cost = Number(parsed.total_cost_usd ?? 0);
|
||||
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
|
||||
const isError = parsed.is_error === true;
|
||||
const resultText = typeof parsed.result === "string" ? parsed.result : "";
|
||||
if (resultText) {
|
||||
console.log(pc.green("result:"));
|
||||
console.log(resultText);
|
||||
}
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(asErrorText).filter(Boolean) : [];
|
||||
if (subtype.startsWith("error") || isError || errors.length > 0) {
|
||||
console.log(pc.red(`claude_result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`));
|
||||
if (errors.length > 0) {
|
||||
console.log(pc.red(`claude_errors: ${errors.join(" | ")}`));
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
pc.blue(
|
||||
`tokens: in=${Number.isFinite(input) ? input : 0} out=${Number.isFinite(output) ? output : 0} cached=${Number.isFinite(cached) ? cached : 0} cost=$${Number.isFinite(cost) ? cost.toFixed(6) : "0.000000"}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(pc.gray(line));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printClaudeStreamEvent } from "./format-event.js";
|
||||
@@ -0,0 +1,37 @@
|
||||
export const type = "claude_local";
|
||||
export const label = "Claude Code (local)";
|
||||
|
||||
export const models = [
|
||||
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
|
||||
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
|
||||
];
|
||||
|
||||
export const agentConfigurationDoc = `# claude_local agent configuration
|
||||
|
||||
Adapter: claude_local
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime
|
||||
- model (string, optional): Claude model id
|
||||
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
|
||||
- chrome (boolean, optional): pass --chrome when running Claude
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- maxTurnsPerRun (number, optional): max turns for one run
|
||||
- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude
|
||||
- command (string, optional): defaults to "claude"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- When AgentCompany realizes a workspace/runtime for a run, it injects AGENT_COMPANY_WORKSPACE_* and AGENT_COMPANY_RUNTIME_* env vars for agent-side tooling.
|
||||
`;
|
||||
@@ -0,0 +1,556 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
|
||||
import type { RunProcessResult } from "@agentcompany/adapter-utils/server-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
asBoolean,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
parseJson,
|
||||
buildAgentCompanyEnv,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
import {
|
||||
parseClaudeStreamJson,
|
||||
describeClaudeFailure,
|
||||
detectClaudeLoginRequired,
|
||||
isClaudeMaxTurnsResult,
|
||||
isClaudeUnknownSessionError,
|
||||
} from "./parse.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
|
||||
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
|
||||
];
|
||||
|
||||
async function resolveAgentCompanySkillsDir(): Promise<string | null> {
|
||||
for (const candidate of SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
|
||||
* the repo's `skills/` directory, so `--add-dir` makes Claude Code discover
|
||||
* them as proper registered skills.
|
||||
*/
|
||||
async function buildSkillsDir(): Promise<string> {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "agent-company-skills-"));
|
||||
const target = path.join(tmp, ".claude", "skills");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
const skillsDir = await resolveAgentCompanySkillsDir();
|
||||
if (!skillsDir) return tmp;
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
await fs.symlink(
|
||||
path.join(skillsDir, entry.name),
|
||||
path.join(target, entry.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
interface ClaudeExecutionInput {
|
||||
runId: string;
|
||||
agent: AdapterExecutionContext["agent"];
|
||||
config: Record<string, unknown>;
|
||||
context: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
interface ClaudeRuntimeConfig {
|
||||
command: string;
|
||||
cwd: string;
|
||||
workspaceId: string | null;
|
||||
workspaceRepoUrl: string | null;
|
||||
workspaceRepoRef: string | null;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
extraArgs: string[];
|
||||
}
|
||||
|
||||
function buildLoginResult(input: {
|
||||
proc: RunProcessResult;
|
||||
loginUrl: string | null;
|
||||
}) {
|
||||
return {
|
||||
exitCode: input.proc.exitCode,
|
||||
signal: input.proc.signal,
|
||||
timedOut: input.proc.timedOut,
|
||||
stdout: input.proc.stdout,
|
||||
stderr: input.proc.stderr,
|
||||
loginUrl: input.loginUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
// Claude uses API-key auth when ANTHROPIC_API_KEY is present; otherwise rely on local login/session auth.
|
||||
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
|
||||
const { runId, agent, config, context, authToken } = input;
|
||||
|
||||
const command = asString(config.command, "claude");
|
||||
const workspaceContext = parseObject(context.agentCompanyWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
|
||||
const workspaceHints = Array.isArray(context.agentCompanyWorkspaces)
|
||||
? context.agentCompanyWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.agentCompanyRuntimeServiceIntents)
|
||||
? context.agentCompanyRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.agentCompanyRuntimeServices)
|
||||
? context.agentCompanyRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimePrimaryUrl = asString(context.agentCompanyRuntimePrimaryUrl, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.AGENT_COMPANY_API_KEY === "string" && envConfig.AGENT_COMPANY_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildAgentCompanyEnv(agent) };
|
||||
env.AGENT_COMPANY_RUN_ID = runId;
|
||||
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
|
||||
if (wakeTaskId) {
|
||||
env.AGENT_COMPANY_TASK_ID = wakeTaskId;
|
||||
}
|
||||
if (wakeReason) {
|
||||
env.AGENT_COMPANY_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.AGENT_COMPANY_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.AGENT_COMPANY_APPROVAL_ID = approvalId;
|
||||
}
|
||||
if (approvalStatus) {
|
||||
env.AGENT_COMPANY_APPROVAL_STATUS = approvalStatus;
|
||||
}
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.AGENT_COMPANY_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.AGENT_COMPANY_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.AGENT_COMPANY_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.AGENT_COMPANY_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.AGENT_COMPANY_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.AGENT_COMPANY_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.AGENT_COMPANY_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.AGENT_COMPANY_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.AGENT_COMPANY_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.AGENT_COMPANY_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.AGENT_COMPANY_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.AGENT_COMPANY_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.AGENT_COMPANY_API_KEY = authToken;
|
||||
}
|
||||
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
return {
|
||||
command,
|
||||
cwd,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
extraArgs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runClaudeLogin(input: {
|
||||
runId: string;
|
||||
agent: AdapterExecutionContext["agent"];
|
||||
config: Record<string, unknown>;
|
||||
context?: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
}) {
|
||||
const onLog = input.onLog ?? (async () => {});
|
||||
const runtime = await buildClaudeRuntimeConfig({
|
||||
runId: input.runId,
|
||||
agent: input.agent,
|
||||
config: input.config,
|
||||
context: input.context ?? {},
|
||||
authToken: input.authToken,
|
||||
});
|
||||
|
||||
const proc = await runChildProcess(input.runId, runtime.command, ["login"], {
|
||||
cwd: runtime.cwd,
|
||||
env: runtime.env,
|
||||
timeoutSec: runtime.timeoutSec,
|
||||
graceSec: runtime.graceSec,
|
||||
onLog,
|
||||
});
|
||||
|
||||
const loginMeta = detectClaudeLoginRequired({
|
||||
parsed: null,
|
||||
stdout: proc.stdout,
|
||||
stderr: proc.stderr,
|
||||
});
|
||||
|
||||
return buildLoginResult({
|
||||
proc,
|
||||
loginUrl: loginMeta.loginUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"{{context.taskTitle}}\n\n{{context.taskDescription}}",
|
||||
);
|
||||
const model = asString(config.model, "");
|
||||
const effort = asString(config.effort, "");
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
const commandNotes = instructionsFilePath
|
||||
? [
|
||||
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
|
||||
]
|
||||
: [];
|
||||
|
||||
const runtimeConfig = await buildClaudeRuntimeConfig({
|
||||
runId,
|
||||
agent,
|
||||
config,
|
||||
context,
|
||||
authToken,
|
||||
});
|
||||
const {
|
||||
command,
|
||||
cwd,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
extraArgs,
|
||||
} = runtimeConfig;
|
||||
const billingType = resolveClaudeBillingType(env);
|
||||
const skillsDir = await buildSkillsDir();
|
||||
|
||||
// When instructionsFilePath is configured, create a combined temp file that
|
||||
// includes both the file content and the path directive, so we only need
|
||||
// --append-system-prompt-file (Claude CLI forbids using both flags together).
|
||||
let effectiveInstructionsFilePath = instructionsFilePath;
|
||||
if (instructionsFilePath) {
|
||||
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
|
||||
const pathDirective = `\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsFileDir}.`;
|
||||
const combinedPath = path.join(skillsDir, "agent-instructions.md");
|
||||
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
|
||||
effectiveInstructionsFilePath = combinedPath;
|
||||
}
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
const prompt = renderTemplate(promptTemplate, {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
|
||||
const buildClaudeArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
||||
if (chrome) args.push("--chrome");
|
||||
if (model) args.push("--model", model);
|
||||
if (effort) args.push("--effort", effort);
|
||||
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
||||
if (effectiveInstructionsFilePath) {
|
||||
args.push("--append-system-prompt-file", effectiveInstructionsFilePath);
|
||||
}
|
||||
args.push("--add-dir", skillsDir);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
return args;
|
||||
};
|
||||
|
||||
const parseFallbackErrorMessage = (proc: RunProcessResult) => {
|
||||
const stderrLine =
|
||||
proc.stderr
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? "";
|
||||
|
||||
if ((proc.exitCode ?? 0) === 0) {
|
||||
return "Failed to parse claude JSON output";
|
||||
}
|
||||
|
||||
return stderrLine
|
||||
? `Claude exited with code ${proc.exitCode ?? -1}: ${stderrLine}`
|
||||
: `Claude exited with code ${proc.exitCode ?? -1}`;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildClaudeArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "claude_local",
|
||||
command,
|
||||
cwd,
|
||||
commandArgs: args,
|
||||
commandNotes,
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdin: prompt,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
|
||||
const parsedStream = parseClaudeStreamJson(proc.stdout);
|
||||
const parsed = parsedStream.resultJson ?? parseJson(proc.stdout);
|
||||
return { proc, parsedStream, parsed };
|
||||
};
|
||||
|
||||
const toAdapterResult = (
|
||||
attempt: {
|
||||
proc: RunProcessResult;
|
||||
parsedStream: ReturnType<typeof parseClaudeStreamJson>;
|
||||
parsed: Record<string, unknown> | null;
|
||||
},
|
||||
opts: { fallbackSessionId: string | null; clearSessionOnMissingSession?: boolean },
|
||||
): AdapterExecutionResult => {
|
||||
const { proc, parsedStream, parsed } = attempt;
|
||||
const loginMeta = detectClaudeLoginRequired({
|
||||
parsed,
|
||||
stdout: proc.stdout,
|
||||
stderr: proc.stderr,
|
||||
});
|
||||
const errorMeta =
|
||||
loginMeta.loginUrl != null
|
||||
? {
|
||||
loginUrl: loginMeta.loginUrl,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (proc.timedOut) {
|
||||
return {
|
||||
exitCode: proc.exitCode,
|
||||
signal: proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
errorCode: "timeout",
|
||||
errorMeta,
|
||||
clearSession: Boolean(opts.clearSessionOnMissingSession),
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
return {
|
||||
exitCode: proc.exitCode,
|
||||
signal: proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage: parseFallbackErrorMessage(proc),
|
||||
errorCode: loginMeta.requiresLogin ? "claude_auth_required" : null,
|
||||
errorMeta,
|
||||
resultJson: {
|
||||
stdout: proc.stdout,
|
||||
stderr: proc.stderr,
|
||||
},
|
||||
clearSession: Boolean(opts.clearSessionOnMissingSession),
|
||||
};
|
||||
}
|
||||
|
||||
const usage =
|
||||
parsedStream.usage ??
|
||||
(() => {
|
||||
const usageObj = parseObject(parsed.usage);
|
||||
return {
|
||||
inputTokens: asNumber(usageObj.input_tokens, 0),
|
||||
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
||||
outputTokens: asNumber(usageObj.output_tokens, 0),
|
||||
};
|
||||
})();
|
||||
|
||||
const resolvedSessionId =
|
||||
parsedStream.sessionId ??
|
||||
(asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId);
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const clearSessionForMaxTurns = isClaudeMaxTurnsResult(parsed);
|
||||
|
||||
return {
|
||||
exitCode: proc.exitCode,
|
||||
signal: proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
(proc.exitCode ?? 0) === 0
|
||||
? null
|
||||
: describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`,
|
||||
errorCode: loginMeta.requiresLogin ? "claude_auth_required" : null,
|
||||
errorMeta,
|
||||
usage,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: "anthropic",
|
||||
model: parsedStream.model || asString(parsed.model, model),
|
||||
billingType,
|
||||
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
|
||||
resultJson: parsed,
|
||||
summary: parsedStream.summary || asString(parsed.result, ""),
|
||||
clearSession: clearSessionForMaxTurns || Boolean(opts.clearSessionOnMissingSession && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const initial = await runAttempt(sessionId ?? null);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
initial.parsed &&
|
||||
isClaudeUnknownSessionError(initial.parsed)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
||||
}
|
||||
|
||||
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
||||
} finally {
|
||||
fs.rm(skillsDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export { execute, runClaudeLogin } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
parseClaudeStreamJson,
|
||||
describeClaudeFailure,
|
||||
isClaudeMaxTurnsResult,
|
||||
isClaudeUnknownSessionError,
|
||||
} from "./parse.js";
|
||||
import type { AdapterSessionCodec } from "@agentcompany/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { UsageSummary } from "@agentcompany/adapter-utils";
|
||||
import { asString, asNumber, parseObject, parseJson } from "@agentcompany/adapter-utils/server-utils";
|
||||
|
||||
const CLAUDE_AUTH_REQUIRED_RE = /(?:not\s+logged\s+in|please\s+log\s+in|please\s+run\s+`?claude\s+login`?|login\s+required|requires\s+login|unauthorized|authentication\s+required)/i;
|
||||
const URL_RE = /(https?:\/\/[^\s'"`<>()[\]{};,!?]+[^\s'"`<>()[\]{};,!.?:]+)/gi;
|
||||
|
||||
export function parseClaudeStreamJson(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
let model = "";
|
||||
let finalResult: Record<string, unknown> | null = null;
|
||||
const assistantTexts: string[] = [];
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const type = asString(event.type, "");
|
||||
if (type === "system" && asString(event.subtype, "") === "init") {
|
||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||
model = asString(event.model, model);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||
const message = parseObject(event.message);
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const entry of content) {
|
||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
||||
const block = entry as Record<string, unknown>;
|
||||
if (asString(block.type, "") === "text") {
|
||||
const text = asString(block.text, "");
|
||||
if (text) assistantTexts.push(text);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
finalResult = event;
|
||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
return {
|
||||
sessionId,
|
||||
model,
|
||||
costUsd: null as number | null,
|
||||
usage: null as UsageSummary | null,
|
||||
summary: assistantTexts.join("\n\n").trim(),
|
||||
resultJson: null as Record<string, unknown> | null,
|
||||
};
|
||||
}
|
||||
|
||||
const usageObj = parseObject(finalResult.usage);
|
||||
const usage: UsageSummary = {
|
||||
inputTokens: asNumber(usageObj.input_tokens, 0),
|
||||
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
||||
outputTokens: asNumber(usageObj.output_tokens, 0),
|
||||
};
|
||||
const costRaw = finalResult.total_cost_usd;
|
||||
const costUsd = typeof costRaw === "number" && Number.isFinite(costRaw) ? costRaw : null;
|
||||
const summary = asString(finalResult.result, assistantTexts.join("\n\n")).trim();
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
model,
|
||||
costUsd,
|
||||
usage,
|
||||
summary,
|
||||
resultJson: finalResult,
|
||||
};
|
||||
}
|
||||
|
||||
function extractClaudeErrorMessages(parsed: Record<string, unknown>): string[] {
|
||||
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
|
||||
const messages: string[] = [];
|
||||
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string") {
|
||||
const msg = entry.trim();
|
||||
if (msg) messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const obj = entry as Record<string, unknown>;
|
||||
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
|
||||
if (msg) {
|
||||
messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
messages.push(JSON.stringify(obj));
|
||||
} catch {
|
||||
// skip non-serializable entry
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function extractClaudeLoginUrl(text: string): string | null {
|
||||
const match = text.match(URL_RE);
|
||||
if (!match || match.length === 0) return null;
|
||||
for (const rawUrl of match) {
|
||||
const cleaned = rawUrl.replace(/[\])}.!,?;:'"]+$/g, "");
|
||||
if (cleaned.includes("claude") || cleaned.includes("anthropic") || cleaned.includes("auth")) {
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
return match[0]?.replace(/[\])}.!,?;:'"]+$/g, "") ?? null;
|
||||
}
|
||||
|
||||
export function detectClaudeLoginRequired(input: {
|
||||
parsed: Record<string, unknown> | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}): { requiresLogin: boolean; loginUrl: string | null } {
|
||||
const resultText = asString(input.parsed?.result, "").trim();
|
||||
const messages = [resultText, ...extractClaudeErrorMessages(input.parsed ?? {}), input.stdout, input.stderr]
|
||||
.join("\n")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const requiresLogin = messages.some((line) => CLAUDE_AUTH_REQUIRED_RE.test(line));
|
||||
return {
|
||||
requiresLogin,
|
||||
loginUrl: extractClaudeLoginUrl([input.stdout, input.stderr].join("\n")),
|
||||
};
|
||||
}
|
||||
|
||||
export function describeClaudeFailure(parsed: Record<string, unknown>): string | null {
|
||||
const subtype = asString(parsed.subtype, "");
|
||||
const resultText = asString(parsed.result, "").trim();
|
||||
const errors = extractClaudeErrorMessages(parsed);
|
||||
|
||||
let detail = resultText;
|
||||
if (!detail && errors.length > 0) {
|
||||
detail = errors[0] ?? "";
|
||||
}
|
||||
|
||||
const parts = ["Claude run failed"];
|
||||
if (subtype) parts.push(`subtype=${subtype}`);
|
||||
if (detail) parts.push(detail);
|
||||
return parts.length > 1 ? parts.join(": ") : null;
|
||||
}
|
||||
|
||||
export function isClaudeMaxTurnsResult(parsed: Record<string, unknown> | null | undefined): boolean {
|
||||
if (!parsed) return false;
|
||||
|
||||
const subtype = asString(parsed.subtype, "").trim().toLowerCase();
|
||||
if (subtype === "error_max_turns") return true;
|
||||
|
||||
const stopReason = asString(parsed.stop_reason, "").trim().toLowerCase();
|
||||
if (stopReason === "max_turns") return true;
|
||||
|
||||
const resultText = asString(parsed.result, "").trim();
|
||||
return /max(?:imum)?\s+turns?/i.test(resultText);
|
||||
}
|
||||
|
||||
export function isClaudeUnknownSessionError(parsed: Record<string, unknown>): boolean {
|
||||
const resultText = asString(parsed.result, "").trim();
|
||||
const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)]
|
||||
.map((msg) => msg.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return allMessages.some((msg) =>
|
||||
/no conversation found with session id|unknown session|session .* not found/i.test(msg),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asBoolean,
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
import path from "node:path";
|
||||
import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function isNonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function commandLooksLike(command: string, expected: string): boolean {
|
||||
const base = path.basename(command).toLowerCase();
|
||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string): string | null {
|
||||
const raw = firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||
if (!raw) return null;
|
||||
const clean = raw.replace(/\s+/g, " ").trim();
|
||||
const max = 240;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "claude");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
checks.push({
|
||||
code: "claude_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "claude_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "claude_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "claude_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
|
||||
const configApiKey = env.ANTHROPIC_API_KEY;
|
||||
const hostApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (isNonEmpty(configApiKey) || isNonEmpty(hostApiKey)) {
|
||||
const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment";
|
||||
checks.push({
|
||||
code: "claude_anthropic_api_key_overrides_subscription",
|
||||
level: "warn",
|
||||
message:
|
||||
"ANTHROPIC_API_KEY is set. Claude will use API-key auth instead of subscription credentials.",
|
||||
detail: `Detected in ${source}.`,
|
||||
hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "claude_subscription_mode_possible",
|
||||
level: "info",
|
||||
message: "ANTHROPIC_API_KEY is not set; subscription-based auth can be used if Claude is logged in.",
|
||||
});
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "claude_cwd_invalid" && check.code !== "claude_command_unresolvable");
|
||||
if (canRunProbe) {
|
||||
if (!commandLooksLike(command, "claude")) {
|
||||
checks.push({
|
||||
code: "claude_hello_probe_skipped_custom_command",
|
||||
level: "info",
|
||||
message: "Skipped hello probe because command is not `claude`.",
|
||||
detail: command,
|
||||
hint: "Use the `claude` CLI command to run the automatic login and installation probe.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, "").trim();
|
||||
const effort = asString(config.effort, "").trim();
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
||||
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
||||
if (chrome) args.push("--chrome");
|
||||
if (model) args.push("--model", model);
|
||||
if (effort) args.push("--effort", effort);
|
||||
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
graceSec: 5,
|
||||
stdin: "Respond with hello.",
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const parsedStream = parseClaudeStreamJson(probe.stdout);
|
||||
const parsed = parsedStream.resultJson;
|
||||
const loginMeta = detectClaudeLoginRequired({
|
||||
parsed,
|
||||
stdout: probe.stdout,
|
||||
stderr: probe.stderr,
|
||||
});
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr);
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "claude_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "Claude hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, verify Claude can run `Respond with hello` from this directory manually.",
|
||||
});
|
||||
} else if (loginMeta.requiresLogin) {
|
||||
checks.push({
|
||||
code: "claude_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Claude CLI is installed, but login is required.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: loginMeta.loginUrl
|
||||
? `Run \`claude login\` and complete sign-in at ${loginMeta.loginUrl}, then retry.`
|
||||
: "Run `claude login` in this environment, then retry the probe.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0) {
|
||||
const summary = parsedStream.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
code: hasHello ? "claude_hello_probe_passed" : "claude_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "Claude hello probe succeeded."
|
||||
: "Claude probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Try the probe manually (`claude --print - --output-format stream-json --verbose`) and prompt `Respond with hello`.",
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "claude_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "Claude hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `claude --print - --output-format stream-json --verbose` manually in this directory and prompt `Respond with hello` to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
|
||||
if (v.chrome) ac.chrome = true;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||
if (v.workspaceStrategyType === "git_worktree") {
|
||||
ac.workspaceStrategy = {
|
||||
type: "git_worktree",
|
||||
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
||||
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
||||
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
||||
};
|
||||
}
|
||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||
ac.workspaceRuntime = runtimeServices;
|
||||
}
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { parseClaudeStdoutLine } from "./parse-stdout.js";
|
||||
export { buildClaudeLocalConfig } from "./build-config.js";
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { TranscriptEntry } from "@agentcompany/adapter-utils";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||
if (type === "system" && parsed.subtype === "init") {
|
||||
return [
|
||||
{
|
||||
kind: "init",
|
||||
ts,
|
||||
model: typeof parsed.model === "string" ? parsed.model : "unknown",
|
||||
sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const message = asRecord(parsed.message) ?? {};
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
const entries: TranscriptEntry[] = [];
|
||||
for (const blockRaw of content) {
|
||||
const block = asRecord(blockRaw);
|
||||
if (!block) continue;
|
||||
const blockType = typeof block.type === "string" ? block.type : "";
|
||||
if (blockType === "text") {
|
||||
const text = typeof block.text === "string" ? block.text : "";
|
||||
if (text) entries.push({ kind: "assistant", ts, text });
|
||||
} else if (blockType === "thinking") {
|
||||
const text = typeof block.thinking === "string" ? block.thinking : "";
|
||||
if (text) entries.push({ kind: "thinking", ts, text });
|
||||
} else if (blockType === "tool_use") {
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: typeof block.name === "string" ? block.name : "unknown",
|
||||
toolUseId:
|
||||
typeof block.id === "string"
|
||||
? block.id
|
||||
: typeof block.tool_use_id === "string"
|
||||
? block.tool_use_id
|
||||
: undefined,
|
||||
input: block.input ?? {},
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries.length > 0 ? entries : [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
const message = asRecord(parsed.message) ?? {};
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
const entries: TranscriptEntry[] = [];
|
||||
for (const blockRaw of content) {
|
||||
const block = asRecord(blockRaw);
|
||||
if (!block) continue;
|
||||
const blockType = typeof block.type === "string" ? block.type : "";
|
||||
if (blockType === "text") {
|
||||
const text = typeof block.text === "string" ? block.text : "";
|
||||
if (text) entries.push({ kind: "user", ts, text });
|
||||
} else if (blockType === "tool_result") {
|
||||
const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
|
||||
const isError = block.is_error === true;
|
||||
let text = "";
|
||||
if (typeof block.content === "string") {
|
||||
text = block.content;
|
||||
} else if (Array.isArray(block.content)) {
|
||||
const parts: string[] = [];
|
||||
for (const part of block.content) {
|
||||
const p = asRecord(part);
|
||||
if (p && typeof p.text === "string") parts.push(p.text);
|
||||
}
|
||||
text = parts.join("\n");
|
||||
}
|
||||
entries.push({ kind: "tool_result", ts, toolUseId, content: text, isError });
|
||||
}
|
||||
}
|
||||
if (entries.length > 0) return entries;
|
||||
// fall through to stdout for user messages without recognized blocks
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage = asRecord(parsed.usage) ?? {};
|
||||
const inputTokens = asNumber(usage.input_tokens);
|
||||
const outputTokens = asNumber(usage.output_tokens);
|
||||
const cachedTokens = asNumber(usage.cache_read_input_tokens);
|
||||
const costUsd = asNumber(parsed.total_cost_usd);
|
||||
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
|
||||
const isError = parsed.is_error === true;
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(errorText).filter(Boolean) : [];
|
||||
const text = typeof parsed.result === "string" ? parsed.result : "";
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd,
|
||||
subtype,
|
||||
isError,
|
||||
errors,
|
||||
}];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# @paperclipai/adapter-codex-local
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.7
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.6
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.5
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.4
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.3
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.2
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.1
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@agentcompany/adapter-codex-local",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentcompany/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const msg =
|
||||
(typeof rec.message === "string" && rec.message) ||
|
||||
(typeof rec.error === "string" && rec.error) ||
|
||||
(typeof rec.code === "string" && rec.code) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function printItemStarted(item: Record<string, unknown>): boolean {
|
||||
const itemType = asString(item.type);
|
||||
if (itemType === "command_execution") {
|
||||
const command = asString(item.command);
|
||||
console.log(pc.yellow("tool_call: command_execution"));
|
||||
if (command) console.log(pc.gray(command));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "tool_use") {
|
||||
const name = asString(item.name, "unknown");
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
if (item.input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(item.input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(item.input)));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function printItemCompleted(item: Record<string, unknown>): boolean {
|
||||
const itemType = asString(item.type);
|
||||
|
||||
if (itemType === "agent_message") {
|
||||
const text = asString(item.text);
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "reasoning") {
|
||||
const text = asString(item.text);
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "tool_use") {
|
||||
const name = asString(item.name, "unknown");
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
if (item.input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(item.input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(item.input)));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "command_execution") {
|
||||
const command = asString(item.command);
|
||||
const status = asString(item.status);
|
||||
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
|
||||
const output = asString(item.aggregated_output).replace(/\s+$/, "");
|
||||
const isError =
|
||||
(exitCode !== null && exitCode !== 0) ||
|
||||
status === "failed" ||
|
||||
status === "errored" ||
|
||||
status === "error" ||
|
||||
status === "cancelled";
|
||||
|
||||
const summaryParts = [
|
||||
"tool_result: command_execution",
|
||||
command ? `command="${command}"` : "",
|
||||
status ? `status=${status}` : "",
|
||||
exitCode !== null ? `exit_code=${exitCode}` : "",
|
||||
].filter(Boolean);
|
||||
console.log((isError ? pc.red : pc.cyan)(summaryParts.join(" ")));
|
||||
if (output) console.log((isError ? pc.red : pc.gray)(output));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "file_change") {
|
||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
||||
const entries = changes
|
||||
.map((changeRaw) => asRecord(changeRaw))
|
||||
.filter((change): change is Record<string, unknown> => Boolean(change))
|
||||
.map((change) => {
|
||||
const kind = asString(change.kind, "update");
|
||||
const path = asString(change.path, "unknown");
|
||||
return `${kind} ${path}`;
|
||||
});
|
||||
const preview = entries.length > 0 ? entries.slice(0, 6).join(", ") : "none";
|
||||
const more = entries.length > 6 ? ` (+${entries.length - 6} more)` : "";
|
||||
console.log(pc.cyan(`file_change: ${preview}${more}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "error") {
|
||||
const message = errorText(item.message ?? item.error ?? item);
|
||||
if (message) console.log(pc.red(`error: ${message}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "tool_result") {
|
||||
const isError = item.is_error === true || asString(item.status) === "error";
|
||||
const text = asString(item.content) || asString(item.result) || asString(item.output);
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
if (text) console.log((isError ? pc.red : pc.gray)(text));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function printCodexStreamEvent(raw: string, debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "thread.started") {
|
||||
const threadId = asString(parsed.thread_id);
|
||||
const model = asString(parsed.model);
|
||||
const details = [threadId ? `session: ${threadId}` : "", model ? `model: ${model}` : ""].filter(Boolean).join(", ");
|
||||
console.log(pc.blue(`Codex thread started${details ? ` (${details})` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "turn.started") {
|
||||
console.log(pc.blue("turn started"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "item.started" || type === "item.completed") {
|
||||
const item = asRecord(parsed.item);
|
||||
if (item) {
|
||||
const handled =
|
||||
type === "item.started"
|
||||
? printItemStarted(item)
|
||||
: printItemCompleted(item);
|
||||
if (!handled) {
|
||||
const itemType = asString(item.type, "unknown");
|
||||
const id = asString(item.id);
|
||||
const status = asString(item.status);
|
||||
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
|
||||
console.log(pc.gray(`${type}: ${itemType}${meta ? ` (${meta})` : ""}`));
|
||||
}
|
||||
} else {
|
||||
console.log(pc.gray(type));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "turn.completed") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const input = asNumber(usage?.input_tokens);
|
||||
const output = asNumber(usage?.output_tokens);
|
||||
const cached = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
||||
const cost = asNumber(parsed.total_cost_usd);
|
||||
const isError = parsed.is_error === true;
|
||||
const subtype = asString(parsed.subtype);
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(errorText).filter(Boolean) : [];
|
||||
|
||||
console.log(
|
||||
pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`),
|
||||
);
|
||||
if (subtype || isError || errors.length > 0) {
|
||||
console.log(
|
||||
pc.red(`result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`),
|
||||
);
|
||||
if (errors.length > 0) console.log(pc.red(`errors: ${errors.join(" | ")}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "turn.failed") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const input = asNumber(usage?.input_tokens);
|
||||
const output = asNumber(usage?.output_tokens);
|
||||
const cached = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
||||
const message = errorText(parsed.error ?? parsed.message);
|
||||
console.log(pc.red(`turn failed${message ? `: ${message}` : ""}`));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = errorText(parsed.message ?? parsed.error ?? parsed);
|
||||
if (message) console.log(pc.red(`error: ${message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(pc.gray(line));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printCodexStreamEvent } from "./format-event.js";
|
||||
@@ -0,0 +1,46 @@
|
||||
export const type = "codex_local";
|
||||
export const label = "Codex (local)";
|
||||
export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex";
|
||||
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
|
||||
|
||||
export const models = [
|
||||
{ id: "gpt-5.4", label: "gpt-5.4" },
|
||||
{ id: DEFAULT_CODEX_LOCAL_MODEL, label: DEFAULT_CODEX_LOCAL_MODEL },
|
||||
{ id: "gpt-5.3-codex-spark", label: "gpt-5.3-codex-spark" },
|
||||
{ id: "gpt-5", label: "gpt-5" },
|
||||
{ id: "o3", label: "o3" },
|
||||
{ id: "o4-mini", label: "o4-mini" },
|
||||
{ id: "gpt-5-mini", label: "gpt-5-mini" },
|
||||
{ id: "gpt-5-nano", label: "gpt-5-nano" },
|
||||
{ id: "o3-mini", label: "o3-mini" },
|
||||
{ id: "codex-mini-latest", label: "Codex Mini" },
|
||||
];
|
||||
|
||||
export const agentConfigurationDoc = `# codex_local agent configuration
|
||||
|
||||
Adapter: codex_local
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime
|
||||
- model (string, optional): Codex model id
|
||||
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=...
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- search (boolean, optional): run codex with --search
|
||||
- dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag
|
||||
- command (string, optional): defaults to "codex"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
||||
- AgentCompany auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$agentcompany" and related skills.
|
||||
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
||||
- When AgentCompany realizes a workspace/runtime for a run, it injects AGENT_COMPANY_WORKSPACE_* and AGENT_COMPANY_RUNTIME_* env vars for agent-side tooling.
|
||||
`;
|
||||
@@ -0,0 +1,444 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
asBoolean,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
buildAgentCompanyEnv,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
|
||||
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
|
||||
];
|
||||
const CODEX_ROLLOUT_NOISE_RE =
|
||||
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
|
||||
|
||||
function stripCodexRolloutNoise(text: string): string {
|
||||
const parts = text.split(/\r?\n/);
|
||||
const kept: string[] = [];
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) {
|
||||
kept.push(part);
|
||||
continue;
|
||||
}
|
||||
if (CODEX_ROLLOUT_NOISE_RE.test(trimmed)) continue;
|
||||
kept.push(part);
|
||||
}
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveCodexBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
// Codex uses API-key auth when OPENAI_API_KEY is present; otherwise rely on local login/session auth.
|
||||
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
function codexHomeDir(): string {
|
||||
const fromEnv = process.env.CODEX_HOME;
|
||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
||||
return path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
async function resolveAgentCompanySkillsDir(): Promise<string | null> {
|
||||
for (const candidate of SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureCodexSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
|
||||
const skillsDir = await resolveAgentCompanySkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
const skillsHome = path.join(codexHomeDir(), "skills");
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) continue;
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Injected Codex skill "${entry.name}" into ${skillsHome}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to inject Codex skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"{{context.taskTitle}}\n\n{{context.taskDescription}}",
|
||||
);
|
||||
const command = asString(config.command, "codex");
|
||||
const model = asString(config.model, "");
|
||||
const modelReasoningEffort = asString(
|
||||
config.modelReasoningEffort,
|
||||
asString(config.reasoningEffort, ""),
|
||||
);
|
||||
const search = asBoolean(config.search, false);
|
||||
const bypass = asBoolean(
|
||||
config.dangerouslyBypassApprovalsAndSandbox,
|
||||
asBoolean(config.dangerouslyBypassSandbox, false),
|
||||
);
|
||||
|
||||
const workspaceContext = parseObject(context.agentCompanyWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "");
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
|
||||
const workspaceHints = Array.isArray(context.agentCompanyWorkspaces)
|
||||
? context.agentCompanyWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.agentCompanyRuntimeServiceIntents)
|
||||
? context.agentCompanyRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.agentCompanyRuntimeServices)
|
||||
? context.agentCompanyRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimePrimaryUrl = asString(context.agentCompanyRuntimePrimaryUrl, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureCodexSkillsInjected(onLog);
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.AGENT_COMPANY_API_KEY === "string" && envConfig.AGENT_COMPANY_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildAgentCompanyEnv(agent) };
|
||||
env.AGENT_COMPANY_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
if (wakeTaskId) {
|
||||
env.AGENT_COMPANY_TASK_ID = wakeTaskId;
|
||||
}
|
||||
if (wakeReason) {
|
||||
env.AGENT_COMPANY_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.AGENT_COMPANY_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.AGENT_COMPANY_APPROVAL_ID = approvalId;
|
||||
}
|
||||
if (approvalStatus) {
|
||||
env.AGENT_COMPANY_APPROVAL_STATUS = approvalStatus;
|
||||
}
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.AGENT_COMPANY_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.AGENT_COMPANY_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.AGENT_COMPANY_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.AGENT_COMPANY_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.AGENT_COMPANY_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.AGENT_COMPANY_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.AGENT_COMPANY_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.AGENT_COMPANY_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.AGENT_COMPANY_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.AGENT_COMPANY_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.AGENT_COMPANY_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.AGENT_COMPANY_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.AGENT_COMPANY_API_KEY = authToken;
|
||||
}
|
||||
const billingType = resolveCodexBillingType(env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const commandNotes = (() => {
|
||||
if (!instructionsFilePath) return [] as string[];
|
||||
if (instructionsPrefix.length > 0) {
|
||||
return [
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
];
|
||||
})();
|
||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
const prompt = `${instructionsPrefix}${renderedPrompt}`;
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["exec", "--json"];
|
||||
if (search) args.unshift("--search");
|
||||
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
||||
if (model) args.push("--model", model);
|
||||
if (modelReasoningEffort) args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
if (resumeSessionId) args.push("resume", resumeSessionId, "-");
|
||||
else args.push("-");
|
||||
return args;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "codex_local",
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args.map((value, idx) => {
|
||||
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
|
||||
return value;
|
||||
}),
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdin: prompt,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog: async (stream, chunk) => {
|
||||
if (stream !== "stderr") {
|
||||
await onLog(stream, chunk);
|
||||
return;
|
||||
}
|
||||
const cleaned = stripCodexRolloutNoise(chunk);
|
||||
if (!cleaned.trim()) return;
|
||||
await onLog(stream, cleaned);
|
||||
},
|
||||
});
|
||||
const cleanedStderr = stripCodexRolloutNoise(proc.stderr);
|
||||
return {
|
||||
proc: {
|
||||
...proc,
|
||||
stderr: cleanedStderr,
|
||||
},
|
||||
rawStderr: proc.stderr,
|
||||
parsed: parseCodexJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const toResult = (
|
||||
attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; rawStderr: string; parsed: ReturnType<typeof parseCodexJsonl> },
|
||||
clearSessionOnMissingSession = false,
|
||||
): AdapterExecutionResult => {
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
stderrLine ||
|
||||
`Codex exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
(attempt.proc.exitCode ?? 0) === 0
|
||||
? null
|
||||
: fallbackErrorMessage,
|
||||
usage: attempt.parsed.usage,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: "openai",
|
||||
model,
|
||||
billingType,
|
||||
costUsd: null,
|
||||
resultJson: {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionId);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true);
|
||||
}
|
||||
|
||||
return toResult(initial);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||
import type { AdapterSessionCodec } from "@agentcompany/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { asString, asNumber, parseObject, parseJson } from "@agentcompany/adapter-utils/server-utils";
|
||||
|
||||
export function parseCodexJsonl(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
const messages: string[] = [];
|
||||
let errorMessage: string | null = null;
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const type = asString(event.type, "");
|
||||
if (type === "thread.started") {
|
||||
sessionId = asString(event.thread_id, sessionId ?? "") || sessionId;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const msg = asString(event.message, "").trim();
|
||||
if (msg) errorMessage = msg;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "item.completed") {
|
||||
const item = parseObject(event.item);
|
||||
if (asString(item.type, "") === "agent_message") {
|
||||
const text = asString(item.text, "");
|
||||
if (text) messages.push(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "turn.completed") {
|
||||
const usageObj = parseObject(event.usage);
|
||||
usage.inputTokens = asNumber(usageObj.input_tokens, usage.inputTokens);
|
||||
usage.cachedInputTokens = asNumber(usageObj.cached_input_tokens, usage.cachedInputTokens);
|
||||
usage.outputTokens = asNumber(usageObj.output_tokens, usage.outputTokens);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "turn.failed") {
|
||||
const err = parseObject(event.error);
|
||||
const msg = asString(err.message, "").trim();
|
||||
if (msg) errorMessage = msg;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
summary: messages.join("\n\n").trim(),
|
||||
usage,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCodexUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
return /unknown (session|thread)|session .* not found|thread .* not found|conversation .* not found|missing rollout path for thread|state db missing rollout path/i.test(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asBoolean,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
import path from "node:path";
|
||||
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;
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "codex");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
checks.push({
|
||||
code: "codex_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "codex_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "codex_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "codex_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
|
||||
const configOpenAiKey = env.OPENAI_API_KEY;
|
||||
const hostOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) {
|
||||
const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment";
|
||||
checks.push({
|
||||
code: "codex_openai_api_key_present",
|
||||
level: "info",
|
||||
message: "OPENAI_API_KEY is set for Codex authentication.",
|
||||
detail: `Detected in ${source}.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "codex_openai_api_key_missing",
|
||||
level: "warn",
|
||||
message: "OPENAI_API_KEY is not set. Codex runs may fail until authentication is configured.",
|
||||
hint: "Set OPENAI_API_KEY in adapter env, shell environment, or Codex auth configuration.",
|
||||
});
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "codex_cwd_invalid" && check.code !== "codex_command_unresolvable");
|
||||
if (canRunProbe) {
|
||||
if (!commandLooksLike(command, "codex")) {
|
||||
checks.push({
|
||||
code: "codex_hello_probe_skipped_custom_command",
|
||||
level: "info",
|
||||
message: "Skipped hello probe because command is not `codex`.",
|
||||
detail: command,
|
||||
hint: "Use the `codex` CLI command to run the automatic login and installation probe.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, "").trim();
|
||||
const modelReasoningEffort = asString(
|
||||
config.modelReasoningEffort,
|
||||
asString(config.reasoningEffort, ""),
|
||||
).trim();
|
||||
const search = asBoolean(config.search, false);
|
||||
const bypass = asBoolean(
|
||||
config.dangerouslyBypassApprovalsAndSandbox,
|
||||
asBoolean(config.dangerouslyBypassSandbox, false),
|
||||
);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const args = ["exec", "--json"];
|
||||
if (search) args.unshift("--search");
|
||||
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
||||
if (model) args.push("--model", model);
|
||||
if (modelReasoningEffort) {
|
||||
args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`);
|
||||
}
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push("-");
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
graceSec: 5,
|
||||
stdin: "Respond with hello.",
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
const parsed = parseCodexJsonl(probe.stdout);
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "codex_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "Codex hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, verify Codex can run `Respond with hello` from this directory manually.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0) {
|
||||
const summary = parsed.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
code: hasHello ? "codex_hello_probe_passed" : "codex_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "Codex hello probe succeeded."
|
||||
: "Codex probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Try the probe manually (`codex exec --json -` then prompt: Respond with hello) to inspect full output.",
|
||||
}),
|
||||
});
|
||||
} else if (CODEX_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||
checks.push({
|
||||
code: "codex_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Codex CLI is installed, but authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Configure OPENAI_API_KEY in adapter env/shell or run `codex login`, then retry the probe.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "codex_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "Codex hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `codex exec --json -` manually in this working directory and prompt `Respond with hello` to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "../index.js";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
ac.model = v.model || DEFAULT_CODEX_LOCAL_MODEL;
|
||||
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.search = v.search;
|
||||
ac.dangerouslyBypassApprovalsAndSandbox =
|
||||
typeof v.dangerouslyBypassSandbox === "boolean"
|
||||
? v.dangerouslyBypassSandbox
|
||||
: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
if (v.workspaceStrategyType === "git_worktree") {
|
||||
ac.workspaceStrategy = {
|
||||
type: "git_worktree",
|
||||
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
||||
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
||||
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
||||
};
|
||||
}
|
||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||
ac.workspaceRuntime = runtimeServices;
|
||||
}
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { parseCodexStdoutLine } from "./parse-stdout.js";
|
||||
export { buildCodexLocalConfig } from "./build-config.js";
|
||||
@@ -0,0 +1,263 @@
|
||||
import {
|
||||
redactHomePathUserSegments,
|
||||
redactHomePathUserSegmentsInValue,
|
||||
type TranscriptEntry,
|
||||
} from "@agentcompany/adapter-utils";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return redactHomePathUserSegments(value);
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(redactHomePathUserSegmentsInValue(value), null, 2);
|
||||
} catch {
|
||||
return redactHomePathUserSegments(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function parseCommandExecutionItem(
|
||||
item: Record<string, unknown>,
|
||||
ts: string,
|
||||
phase: "started" | "completed",
|
||||
): TranscriptEntry[] {
|
||||
const id = asString(item.id);
|
||||
const command = asString(item.command);
|
||||
const status = asString(item.status);
|
||||
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
|
||||
const safeCommand = redactHomePathUserSegments(command);
|
||||
const output = redactHomePathUserSegments(asString(item.aggregated_output)).replace(/\s+$/, "");
|
||||
|
||||
if (phase === "started") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: "command_execution",
|
||||
toolUseId: id || command || "command_execution",
|
||||
input: {
|
||||
id,
|
||||
command: safeCommand,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (safeCommand) lines.push(`command: ${safeCommand}`);
|
||||
if (status) lines.push(`status: ${status}`);
|
||||
if (exitCode !== null) lines.push(`exit_code: ${exitCode}`);
|
||||
if (output) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push(output);
|
||||
}
|
||||
|
||||
const isError =
|
||||
(exitCode !== null && exitCode !== 0) ||
|
||||
status === "failed" ||
|
||||
status === "errored" ||
|
||||
status === "error" ||
|
||||
status === "cancelled";
|
||||
|
||||
return [{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: id || command || "command_execution",
|
||||
content: lines.join("\n").trim() || "command completed",
|
||||
isError,
|
||||
}];
|
||||
}
|
||||
|
||||
function parseFileChangeItem(item: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
||||
const entries = changes
|
||||
.map((changeRaw) => asRecord(changeRaw))
|
||||
.filter((change): change is Record<string, unknown> => Boolean(change))
|
||||
.map((change) => {
|
||||
const kind = asString(change.kind, "update");
|
||||
const path = redactHomePathUserSegments(asString(change.path, "unknown"));
|
||||
return `${kind} ${path}`;
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
return [{ kind: "system", ts, text: "file changes applied" }];
|
||||
}
|
||||
|
||||
const preview = entries.slice(0, 6).join(", ");
|
||||
const more = entries.length > 6 ? ` (+${entries.length - 6} more)` : "";
|
||||
return [{ kind: "system", ts, text: `file changes: ${preview}${more}` }];
|
||||
}
|
||||
|
||||
function parseCodexItem(
|
||||
item: Record<string, unknown>,
|
||||
ts: string,
|
||||
phase: "started" | "completed",
|
||||
): TranscriptEntry[] {
|
||||
const itemType = asString(item.type);
|
||||
|
||||
if (itemType === "agent_message") {
|
||||
const text = asString(item.text);
|
||||
if (text) return [{ kind: "assistant", ts, text: redactHomePathUserSegments(text) }];
|
||||
return [];
|
||||
}
|
||||
|
||||
if (itemType === "reasoning") {
|
||||
const text = asString(item.text);
|
||||
if (text) return [{ kind: "thinking", ts, text: redactHomePathUserSegments(text) }];
|
||||
return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }];
|
||||
}
|
||||
|
||||
if (itemType === "command_execution") {
|
||||
return parseCommandExecutionItem(item, ts, phase);
|
||||
}
|
||||
|
||||
if (itemType === "file_change" && phase === "completed") {
|
||||
return parseFileChangeItem(item, ts);
|
||||
}
|
||||
|
||||
if (itemType === "tool_use") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: redactHomePathUserSegments(asString(item.name, "unknown")),
|
||||
toolUseId: asString(item.id),
|
||||
input: redactHomePathUserSegmentsInValue(item.input ?? {}),
|
||||
}];
|
||||
}
|
||||
|
||||
if (itemType === "tool_result" && phase === "completed") {
|
||||
const toolUseId = asString(item.tool_use_id, asString(item.id));
|
||||
const content =
|
||||
asString(item.content) ||
|
||||
asString(item.output) ||
|
||||
asString(item.result) ||
|
||||
stringifyUnknown(item.content ?? item.output ?? item.result);
|
||||
const isError = item.is_error === true || asString(item.status) === "error";
|
||||
return [{ kind: "tool_result", ts, toolUseId, content: redactHomePathUserSegments(content), isError }];
|
||||
}
|
||||
|
||||
if (itemType === "error" && phase === "completed") {
|
||||
const text = errorText(item.message ?? item.error ?? item);
|
||||
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(text || "error") }];
|
||||
}
|
||||
|
||||
const id = asString(item.id);
|
||||
const status = asString(item.status);
|
||||
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
|
||||
return [{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: redactHomePathUserSegments(`item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`),
|
||||
}];
|
||||
}
|
||||
|
||||
export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "thread.started") {
|
||||
const threadId = asString(parsed.thread_id);
|
||||
return [{
|
||||
kind: "init",
|
||||
ts,
|
||||
model: redactHomePathUserSegments(asString(parsed.model, "codex")),
|
||||
sessionId: redactHomePathUserSegments(threadId),
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "turn.started") {
|
||||
return [{ kind: "system", ts, text: "turn started" }];
|
||||
}
|
||||
|
||||
if (type === "item.started" || type === "item.completed") {
|
||||
const item = asRecord(parsed.item);
|
||||
if (!item) return [{ kind: "system", ts, text: type.replace(".", " ") }];
|
||||
return parseCodexItem(item, ts, type === "item.started" ? "started" : "completed");
|
||||
}
|
||||
|
||||
if (type === "turn.completed") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const inputTokens = asNumber(usage?.input_tokens);
|
||||
const outputTokens = asNumber(usage?.output_tokens);
|
||||
const cachedTokens = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: redactHomePathUserSegments(asString(parsed.result)),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd),
|
||||
subtype: redactHomePathUserSegments(asString(parsed.subtype)),
|
||||
isError: parsed.is_error === true,
|
||||
errors: Array.isArray(parsed.errors)
|
||||
? parsed.errors.map(errorText).map(redactHomePathUserSegments).filter(Boolean)
|
||||
: [],
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "turn.failed") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const inputTokens = asNumber(usage?.input_tokens);
|
||||
const outputTokens = asNumber(usage?.output_tokens);
|
||||
const cachedTokens = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
||||
const message = errorText(parsed.error ?? parsed.message);
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: redactHomePathUserSegments(asString(parsed.result)),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd),
|
||||
subtype: redactHomePathUserSegments(asString(parsed.subtype, "turn.failed")),
|
||||
isError: true,
|
||||
errors: message ? [redactHomePathUserSegments(message)] : [],
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = errorText(parsed.message ?? parsed.error ?? parsed);
|
||||
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(message || line) }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@agentcompany/adapter-gemini-local",
|
||||
"version": "0.2.7",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentcompany/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const msg =
|
||||
(typeof rec.message === "string" && rec.message) ||
|
||||
(typeof rec.error === "string" && rec.error) ||
|
||||
(typeof rec.code === "string" && rec.code) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function printTextMessage(prefix: string, colorize: (text: string) => string, messageRaw: unknown): void {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
if (text) console.log(colorize(`${prefix}: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return;
|
||||
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) console.log(colorize(`${prefix}: ${directText}`));
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
|
||||
if (type === "output_text" || type === "text" || type === "content") {
|
||||
const text = asString(part.text).trim() || asString(part.content).trim();
|
||||
if (text) console.log(colorize(`${prefix}: ${text}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const name = asString(part.name, asString(part.tool, "tool"));
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
const input = part.input ?? part.arguments ?? part.args;
|
||||
if (input !== undefined) console.log(pc.gray(stringifyUnknown(input)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result" || type === "tool_response") {
|
||||
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||
const contentText =
|
||||
asString(part.output) ||
|
||||
asString(part.text) ||
|
||||
asString(part.result) ||
|
||||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
if (contentText) console.log((isError ? pc.red : pc.gray)(contentText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage(parsed: Record<string, unknown>) {
|
||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
|
||||
const usageMetadata = asRecord(usage?.usageMetadata);
|
||||
const source = usageMetadata ?? usage ?? {};
|
||||
const input = asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount)));
|
||||
const output = asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount)));
|
||||
const cached = asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
|
||||
);
|
||||
const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
}
|
||||
|
||||
export function printGeminiStreamEvent(raw: string, debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(parsed.subtype);
|
||||
if (subtype === "init") {
|
||||
const sessionId =
|
||||
asString(parsed.session_id) ||
|
||||
asString(parsed.sessionId) ||
|
||||
asString(parsed.sessionID) ||
|
||||
asString(parsed.checkpoint_id);
|
||||
const model = asString(parsed.model);
|
||||
const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
console.log(pc.blue(`Gemini init${details ? ` (${details})` : ""}`));
|
||||
return;
|
||||
}
|
||||
if (subtype === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||
if (text) console.log(pc.red(`error: ${text}`));
|
||||
return;
|
||||
}
|
||||
console.log(pc.blue(`system: ${subtype || "event"}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
printTextMessage("assistant", pc.green, parsed.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
printTextMessage("user", pc.gray, parsed.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
||||
const [toolName] = toolCall ? Object.keys(toolCall) : [];
|
||||
if (!toolCall || !toolName) {
|
||||
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
|
||||
return;
|
||||
}
|
||||
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||
if (subtype === "started" || subtype === "start") {
|
||||
console.log(pc.yellow(`tool_call: ${toolName}`));
|
||||
console.log(pc.gray(stringifyUnknown(payload.args ?? payload.input ?? payload.arguments ?? payload)));
|
||||
return;
|
||||
}
|
||||
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||
const isError =
|
||||
parsed.is_error === true ||
|
||||
payload.is_error === true ||
|
||||
payload.error !== undefined ||
|
||||
asString(payload.status).toLowerCase() === "error";
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
console.log((isError ? pc.red : pc.gray)(stringifyUnknown(payload.result ?? payload.output ?? payload.error)));
|
||||
return;
|
||||
}
|
||||
console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
printUsage(parsed);
|
||||
const subtype = asString(parsed.subtype, "result");
|
||||
const isError = parsed.is_error === true;
|
||||
if (subtype || isError) {
|
||||
console.log((isError ? pc.red : pc.blue)(`result: subtype=${subtype} is_error=${isError ? "true" : "false"}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||
if (text) console.log(pc.red(`error: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(pc.gray(line));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printGeminiStreamEvent } from "./format-event.js";
|
||||
@@ -0,0 +1,47 @@
|
||||
export const type = "gemini_local";
|
||||
export const label = "Gemini CLI (local)";
|
||||
export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
|
||||
|
||||
export const models = [
|
||||
{ id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" },
|
||||
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
|
||||
{ id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
||||
{ id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
|
||||
];
|
||||
|
||||
export const agentConfigurationDoc = `# gemini_local agent configuration
|
||||
|
||||
Adapter: gemini_local
|
||||
|
||||
Use when:
|
||||
- You want AgentCompany to run the Gemini CLI locally on the host machine
|
||||
- You want Gemini chat sessions resumed across heartbeats with --resume
|
||||
- You want AgentCompany skills injected locally without polluting the global environment
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use http or openclaw_gateway)
|
||||
- You only need a one-shot script without an AI coding agent loop (use process)
|
||||
- Gemini CLI is not installed on the machine that runs AgentCompany
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- model (string, optional): Gemini model id. Defaults to auto.
|
||||
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
|
||||
- command (string, optional): defaults to "gemini"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- Runs use positional prompt arguments, not stdin.
|
||||
- Sessions resume with --resume when stored session cwd matches the current cwd.
|
||||
- AgentCompany auto-injects local skills into \`~/.gemini/skills/\` via symlinks, so the CLI can discover both credentials and skills in their natural location.
|
||||
- Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login.
|
||||
`;
|
||||
@@ -0,0 +1,436 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
asBoolean,
|
||||
asNumber,
|
||||
asString,
|
||||
asStringArray,
|
||||
buildAgentCompanyEnv,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
parseObject,
|
||||
redactEnvForLogs,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||
import {
|
||||
describeGeminiFailure,
|
||||
detectGeminiAuthRequired,
|
||||
isGeminiTurnLimitResult,
|
||||
isGeminiUnknownSessionError,
|
||||
parseGeminiJsonl,
|
||||
} from "./parse.js";
|
||||
import { firstNonEmptyLine } from "./utils.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"),
|
||||
path.resolve(__moduleDir, "../../../../../skills"),
|
||||
];
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
|
||||
? "api"
|
||||
: "subscription";
|
||||
}
|
||||
|
||||
function renderEnvNote(env: Record<string, string>): string {
|
||||
const agentCompanyKeys = Object.keys(env)
|
||||
.filter((key) => key.startsWith("AGENT_COMPANY_"))
|
||||
.sort();
|
||||
if (agentCompanyKeys.length === 0) return "";
|
||||
return [
|
||||
"Runtime note:",
|
||||
`The following AGENT_COMPANY_* environment variables are available in this run: ${agentCompanyKeys.join(", ")}`,
|
||||
"Do not assume these variables are missing without checking your shell environment.",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderApiAccessNote(env: Record<string, string>): string {
|
||||
if (!hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_URL") || !hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_KEY")) return "";
|
||||
return [
|
||||
"API access note:",
|
||||
"Use run_shell_command with curl to make API requests.",
|
||||
"GET example:",
|
||||
` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" \\"$AGENT_COMPANY_API_URL/api/agents/me\\"" })`,
|
||||
"POST/PATCH example:",
|
||||
` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Agent-Company-Run-Id: $AGENT_COMPANY_RUN_ID\\" -d '{...}' \\"$AGENT_COMPANY_API_URL/api/issues/{id}/checkout\\"" })`,
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function resolveAgentCompanySkillsDir(): Promise<string | null> {
|
||||
for (const candidate of SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function geminiSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".gemini", "skills");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject AgentCompany skills directly into `~/.gemini/skills/` via symlinks.
|
||||
* This avoids needing GEMINI_CLI_HOME overrides, so the CLI naturally finds
|
||||
* both its auth credentials and the injected skills in the real home directory.
|
||||
*/
|
||||
async function ensureGeminiSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
): Promise<void> {
|
||||
const skillsDir = await resolveAgentCompanySkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
const skillsHome = geminiSkillsHome();
|
||||
try {
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to read AgentCompany skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) continue;
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
await onLog("stderr", `[agent-company] Linked Gemini skill: ${entry.name}\n`);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"{{context.taskTitle}}\n\n{{context.taskDescription}}",
|
||||
);
|
||||
const command = asString(config.command, "gemini");
|
||||
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||
const sandbox = asBoolean(config.sandbox, false);
|
||||
|
||||
const workspaceContext = parseObject(context.agentCompanyWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceHints = Array.isArray(context.agentCompanyWorkspaces)
|
||||
? context.agentCompanyWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureGeminiSkillsInjected(onLog);
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.AGENT_COMPANY_API_KEY === "string" && envConfig.AGENT_COMPANY_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildAgentCompanyEnv(agent) };
|
||||
env.AGENT_COMPANY_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
if (wakeTaskId) env.AGENT_COMPANY_TASK_ID = wakeTaskId;
|
||||
if (wakeReason) env.AGENT_COMPANY_WAKE_REASON = wakeReason;
|
||||
if (wakeCommentId) env.AGENT_COMPANY_WAKE_COMMENT_ID = wakeCommentId;
|
||||
if (approvalId) env.AGENT_COMPANY_APPROVAL_ID = approvalId;
|
||||
if (approvalStatus) env.AGENT_COMPANY_APPROVAL_STATUS = approvalStatus;
|
||||
if (linkedIssueIds.length > 0) env.AGENT_COMPANY_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (effectiveWorkspaceCwd) env.AGENT_COMPANY_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
if (workspaceSource) env.AGENT_COMPANY_WORKSPACE_SOURCE = workspaceSource;
|
||||
if (workspaceId) env.AGENT_COMPANY_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.AGENT_COMPANY_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.AGENT_COMPANY_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (workspaceHints.length > 0) env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.AGENT_COMPANY_API_KEY = authToken;
|
||||
}
|
||||
const billingType = resolveGeminiBillingType(env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const commandNotes = (() => {
|
||||
const notes: string[] = ["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) {
|
||||
notes.push(
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||
);
|
||||
return notes;
|
||||
}
|
||||
notes.push(
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
);
|
||||
return notes;
|
||||
})();
|
||||
|
||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
const envNote = renderEnvNote(env);
|
||||
const apiAccessNote = renderApiAccessNote(env);
|
||||
const prompt = `${instructionsPrefix}${envNote}${apiAccessNote}${renderedPrompt}`;
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["--output-format", "stream-json"];
|
||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
||||
args.push("--approval-mode", "yolo");
|
||||
if (sandbox) {
|
||||
args.push("--sandbox");
|
||||
} else {
|
||||
args.push("--sandbox=none");
|
||||
}
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push(prompt);
|
||||
return args;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "gemini_local",
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args.map((value, index) => (
|
||||
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
|
||||
)),
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
return {
|
||||
proc,
|
||||
parsed: parseGeminiJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const toResult = (
|
||||
attempt: {
|
||||
proc: {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
parsed: ReturnType<typeof parseGeminiJsonl>;
|
||||
},
|
||||
clearSessionOnMissingSession = false,
|
||||
isRetry = false,
|
||||
): AdapterExecutionResult => {
|
||||
const authMeta = detectGeminiAuthRequired({
|
||||
parsed: attempt.parsed.resultEvent,
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
});
|
||||
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
errorCode: authMeta.requiresAuth ? "gemini_auth_required" : null,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode);
|
||||
|
||||
// On retry, don't fall back to old session ID — the old session was stale
|
||||
const canFallbackToRuntimeSession = !isRetry;
|
||||
const resolvedSessionId = attempt.parsed.sessionId
|
||||
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const structuredFailure = attempt.parsed.resultEvent
|
||||
? describeGeminiFailure(attempt.parsed.resultEvent)
|
||||
: null;
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
structuredFailure ||
|
||||
stderrLine ||
|
||||
`Gemini exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage: (attempt.proc.exitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
||||
errorCode: (attempt.proc.exitCode ?? 0) !== 0 && authMeta.requiresAuth ? "gemini_auth_required" : null,
|
||||
usage: attempt.parsed.usage,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: "google",
|
||||
model,
|
||||
billingType,
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
resultJson: attempt.parsed.resultEvent ?? {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
question: attempt.parsed.question,
|
||||
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionId);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true, true);
|
||||
}
|
||||
|
||||
return toResult(initial);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
parseGeminiJsonl,
|
||||
isGeminiUnknownSessionError,
|
||||
describeGeminiFailure,
|
||||
detectGeminiAuthRequired,
|
||||
isGeminiTurnLimitResult,
|
||||
} from "./parse.js";
|
||||
import type { AdapterSessionCodec } from "@agentcompany/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId =
|
||||
readNonEmptyString(record.sessionId) ??
|
||||
readNonEmptyString(record.session_id) ??
|
||||
readNonEmptyString(record.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return (
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID)
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
import { asNumber, asString, parseJson, parseObject } from "@agentcompany/adapter-utils/server-utils";
|
||||
|
||||
function collectMessageText(message: unknown): string[] {
|
||||
if (typeof message === "string") {
|
||||
const trimmed = message.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
const record = parseObject(message);
|
||||
const direct = asString(record.text, "").trim();
|
||||
const lines: string[] = direct ? [direct] : [];
|
||||
const content = Array.isArray(record.content) ? record.content : [];
|
||||
|
||||
for (const partRaw of content) {
|
||||
const part = parseObject(partRaw);
|
||||
const type = asString(part.type, "").trim();
|
||||
if (type === "output_text" || type === "text" || type === "content") {
|
||||
const text = asString(part.text, "").trim() || asString(part.content, "").trim();
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function readSessionId(event: Record<string, unknown>): string | null {
|
||||
return (
|
||||
asString(event.session_id, "").trim() ||
|
||||
asString(event.sessionId, "").trim() ||
|
||||
asString(event.sessionID, "").trim() ||
|
||||
asString(event.checkpoint_id, "").trim() ||
|
||||
asString(event.thread_id, "").trim() ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function asErrorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = parseObject(value);
|
||||
const message =
|
||||
asString(rec.message, "") ||
|
||||
asString(rec.error, "") ||
|
||||
asString(rec.code, "") ||
|
||||
asString(rec.detail, "");
|
||||
if (message) return message;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function accumulateUsage(
|
||||
target: { inputTokens: number; cachedInputTokens: number; outputTokens: number },
|
||||
usageRaw: unknown,
|
||||
) {
|
||||
const usage = parseObject(usageRaw);
|
||||
const usageMetadata = parseObject(usage.usageMetadata);
|
||||
const source = Object.keys(usageMetadata).length > 0 ? usageMetadata : usage;
|
||||
|
||||
target.inputTokens += asNumber(
|
||||
source.input_tokens,
|
||||
asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0)),
|
||||
);
|
||||
target.cachedInputTokens += asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)),
|
||||
);
|
||||
target.outputTokens += asNumber(
|
||||
source.output_tokens,
|
||||
asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0)),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseGeminiJsonl(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
const messages: string[] = [];
|
||||
let errorMessage: string | null = null;
|
||||
let costUsd: number | null = null;
|
||||
let resultEvent: Record<string, unknown> | null = null;
|
||||
let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null;
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const foundSessionId = readSessionId(event);
|
||||
if (foundSessionId) sessionId = foundSessionId;
|
||||
|
||||
const type = asString(event.type, "").trim();
|
||||
|
||||
if (type === "assistant") {
|
||||
messages.push(...collectMessageText(event.message));
|
||||
const messageObj = parseObject(event.message);
|
||||
const content = Array.isArray(messageObj.content) ? messageObj.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = parseObject(partRaw);
|
||||
if (asString(part.type, "").trim() === "question") {
|
||||
question = {
|
||||
prompt: asString(part.prompt, "").trim(),
|
||||
choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => {
|
||||
const choice = parseObject(choiceRaw);
|
||||
return {
|
||||
key: asString(choice.key, "").trim(),
|
||||
label: asString(choice.label, "").trim(),
|
||||
description: asString(choice.description, "").trim() || undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
break; // only one question per message
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
resultEvent = event;
|
||||
accumulateUsage(usage, event.usage ?? event.usageMetadata);
|
||||
const resultText =
|
||||
asString(event.result, "").trim() ||
|
||||
asString(event.text, "").trim() ||
|
||||
asString(event.response, "").trim();
|
||||
if (resultText && messages.length === 0) messages.push(resultText);
|
||||
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
|
||||
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
|
||||
if (isError) {
|
||||
const text = asErrorText(event.error ?? event.message ?? event.result).trim();
|
||||
if (text) errorMessage = text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
|
||||
if (text) errorMessage = text;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(event.subtype, "").trim().toLowerCase();
|
||||
if (subtype === "error") {
|
||||
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
|
||||
if (text) errorMessage = text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const part = parseObject(event.part);
|
||||
const text = asString(part.text, "").trim();
|
||||
if (text) messages.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "step_finish" || event.usage || event.usageMetadata) {
|
||||
accumulateUsage(usage, event.usage ?? event.usageMetadata);
|
||||
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
summary: messages.join("\n\n").trim(),
|
||||
usage,
|
||||
costUsd,
|
||||
errorMessage,
|
||||
resultEvent,
|
||||
question,
|
||||
};
|
||||
}
|
||||
|
||||
export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
|
||||
function extractGeminiErrorMessages(parsed: Record<string, unknown>): string[] {
|
||||
const messages: string[] = [];
|
||||
const errorMsg = asString(parsed.error, "").trim();
|
||||
if (errorMsg) messages.push(errorMsg);
|
||||
|
||||
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string") {
|
||||
const msg = entry.trim();
|
||||
if (msg) messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
||||
const obj = entry as Record<string, unknown>;
|
||||
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
|
||||
if (msg) {
|
||||
messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
messages.push(JSON.stringify(obj));
|
||||
} catch {
|
||||
// skip non-serializable entry
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function describeGeminiFailure(parsed: Record<string, unknown>): string | null {
|
||||
const status = asString(parsed.status, "");
|
||||
const errors = extractGeminiErrorMessages(parsed);
|
||||
|
||||
const detail = errors[0] ?? "";
|
||||
const parts = ["Gemini run failed"];
|
||||
if (status) parts.push(`status=${status}`);
|
||||
if (detail) parts.push(detail);
|
||||
return parts.length > 1 ? parts.join(": ") : null;
|
||||
}
|
||||
|
||||
const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i;
|
||||
|
||||
export function detectGeminiAuthRequired(input: {
|
||||
parsed: Record<string, unknown> | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}): { requiresAuth: boolean } {
|
||||
const errors = extractGeminiErrorMessages(input.parsed ?? {});
|
||||
const messages = [...errors, input.stdout, input.stderr]
|
||||
.join("\n")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const requiresAuth = messages.some((line) => GEMINI_AUTH_REQUIRED_RE.test(line));
|
||||
return { requiresAuth };
|
||||
}
|
||||
|
||||
export function isGeminiTurnLimitResult(
|
||||
parsed: Record<string, unknown> | null | undefined,
|
||||
exitCode?: number | null,
|
||||
): boolean {
|
||||
if (exitCode === 53) return true;
|
||||
if (!parsed) return false;
|
||||
|
||||
const status = asString(parsed.status, "").trim().toLowerCase();
|
||||
if (status === "turn_limit" || status === "max_turns") return true;
|
||||
|
||||
const error = asString(parsed.error, "").trim();
|
||||
return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import path from "node:path";
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
asBoolean,
|
||||
asString,
|
||||
asStringArray,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
parseObject,
|
||||
runChildProcess,
|
||||
} from "@agentcompany/adapter-utils/server-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,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "gemini");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
checks.push({
|
||||
code: "gemini_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "gemini_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "gemini_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "gemini_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
|
||||
const configGeminiApiKey = env.GEMINI_API_KEY;
|
||||
const hostGeminiApiKey = process.env.GEMINI_API_KEY;
|
||||
const configGoogleApiKey = env.GOOGLE_API_KEY;
|
||||
const hostGoogleApiKey = process.env.GOOGLE_API_KEY;
|
||||
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true";
|
||||
if (
|
||||
isNonEmpty(configGeminiApiKey) ||
|
||||
isNonEmpty(hostGeminiApiKey) ||
|
||||
isNonEmpty(configGoogleApiKey) ||
|
||||
isNonEmpty(hostGoogleApiKey) ||
|
||||
hasGca
|
||||
) {
|
||||
const source = hasGca
|
||||
? "Google account login (GCA)"
|
||||
: isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey)
|
||||
? "adapter config env"
|
||||
: "server environment";
|
||||
checks.push({
|
||||
code: "gemini_api_key_present",
|
||||
level: "info",
|
||||
message: "Gemini API credentials are set for CLI authentication.",
|
||||
detail: `Detected in ${source}.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "gemini_api_key_missing",
|
||||
level: "info",
|
||||
message: "No explicit API key detected. Gemini CLI may still authenticate via `gemini auth login` (OAuth).",
|
||||
hint: "If the hello probe fails with an auth error, set GEMINI_API_KEY or GOOGLE_API_KEY in adapter env, or run `gemini auth login`.",
|
||||
});
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "gemini_cwd_invalid" && check.code !== "gemini_command_unresolvable");
|
||||
if (canRunProbe) {
|
||||
if (!commandLooksLike(command, "gemini")) {
|
||||
checks.push({
|
||||
code: "gemini_hello_probe_skipped_custom_command",
|
||||
level: "info",
|
||||
message: "Skipped hello probe because command is not `gemini`.",
|
||||
detail: command,
|
||||
hint: "Use the `gemini` CLI command to run the automatic installation and auth probe.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
|
||||
const sandbox = asBoolean(config.sandbox, false);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const args = ["--output-format", "stream-json"];
|
||||
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
||||
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
|
||||
if (sandbox) {
|
||||
args.push("--sandbox");
|
||||
} else {
|
||||
args.push("--sandbox=none");
|
||||
}
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push("Respond with hello.");
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
graceSec: 5,
|
||||
onLog: async () => { },
|
||||
},
|
||||
);
|
||||
const parsed = parseGeminiJsonl(probe.stdout);
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||
const authMeta = detectGeminiAuthRequired({
|
||||
parsed: parsed.resultEvent,
|
||||
stdout: probe.stdout,
|
||||
stderr: probe.stderr,
|
||||
});
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "gemini_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "Gemini hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, verify Gemini can run `Respond with hello.` from this directory manually.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0) {
|
||||
const summary = parsed.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
code: hasHello ? "gemini_hello_probe_passed" : "gemini_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "Gemini hello probe succeeded."
|
||||
: "Gemini probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Try `gemini --output-format json \"Respond with hello.\"` manually to inspect full output.",
|
||||
}),
|
||||
});
|
||||
} else if (authMeta.requiresAuth) {
|
||||
checks.push({
|
||||
code: "gemini_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Gemini CLI is installed, but authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `gemini auth` or configure GEMINI_API_KEY / GOOGLE_API_KEY in adapter env/shell, then retry the probe.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "gemini_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "Gemini hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `gemini --output-format json \"Respond with hello.\"` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.sandbox = !v.dangerouslyBypassSandbox;
|
||||
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { parseGeminiStdoutLine } from "./parse-stdout.js";
|
||||
export { buildGeminiLocalConfig } from "./build-config.js";
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { TranscriptEntry } from "@agentcompany/adapter-utils";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
|
||||
function collectTextEntries(messageRaw: unknown, ts: string, kind: "assistant" | "user"): TranscriptEntry[] {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
return text ? [{ kind, ts, text }] : [];
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return [];
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) entries.push({ kind, ts, text: directText });
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
if (type !== "output_text" && type !== "text" && type !== "content") continue;
|
||||
const text = asString(part.text).trim() || asString(part.content).trim();
|
||||
if (text) entries.push({ kind, ts, text });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
return text ? [{ kind: "assistant", ts, text }] : [];
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return [];
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) entries.push({ kind: "assistant", ts, text: directText });
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
|
||||
if (type === "output_text" || type === "text" || type === "content") {
|
||||
const text = asString(part.text).trim() || asString(part.content).trim();
|
||||
if (text) entries.push({ kind: "assistant", ts, text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) entries.push({ kind: "thinking", ts, text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const name = asString(part.name, asString(part.tool, "tool"));
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name,
|
||||
input: part.input ?? part.arguments ?? part.args ?? {},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result" || type === "tool_response") {
|
||||
const toolUseId =
|
||||
asString(part.tool_use_id) ||
|
||||
asString(part.toolUseId) ||
|
||||
asString(part.call_id) ||
|
||||
asString(part.id) ||
|
||||
"tool_result";
|
||||
const contentText =
|
||||
asString(part.output) ||
|
||||
asString(part.text) ||
|
||||
asString(part.result) ||
|
||||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
|
||||
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||
entries.push({
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId,
|
||||
content: contentText,
|
||||
isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseTopLevelToolEvent(parsed: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||
const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "tool_call")));
|
||||
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
||||
if (!toolCall) {
|
||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||
}
|
||||
|
||||
const [toolName] = Object.keys(toolCall);
|
||||
if (!toolName) {
|
||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||
}
|
||||
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||
|
||||
if (subtype === "started" || subtype === "start") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
input: payload.args ?? payload.input ?? payload.arguments ?? payload,
|
||||
}];
|
||||
}
|
||||
|
||||
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||
const result = payload.result ?? payload.output ?? payload.error;
|
||||
const isError =
|
||||
parsed.is_error === true ||
|
||||
payload.is_error === true ||
|
||||
payload.error !== undefined ||
|
||||
asString(payload.status).toLowerCase() === "error";
|
||||
return [{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: callId,
|
||||
content: result !== undefined ? stringifyUnknown(result) : `${toolName} completed`,
|
||||
isError,
|
||||
}];
|
||||
}
|
||||
|
||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}` }];
|
||||
}
|
||||
|
||||
function readSessionId(parsed: Record<string, unknown>): string {
|
||||
return (
|
||||
asString(parsed.session_id) ||
|
||||
asString(parsed.sessionId) ||
|
||||
asString(parsed.sessionID) ||
|
||||
asString(parsed.checkpoint_id) ||
|
||||
asString(parsed.thread_id)
|
||||
);
|
||||
}
|
||||
|
||||
function readUsage(parsed: Record<string, unknown>) {
|
||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
|
||||
const usageMetadata = asRecord(usage?.usageMetadata);
|
||||
const source = usageMetadata ?? usage ?? {};
|
||||
return {
|
||||
inputTokens: asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount))),
|
||||
outputTokens: asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))),
|
||||
cachedTokens: asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(parsed.subtype);
|
||||
if (subtype === "init") {
|
||||
const sessionId = readSessionId(parsed);
|
||||
return [{ kind: "init", ts, model: asString(parsed.model, "gemini"), sessionId }];
|
||||
}
|
||||
if (subtype === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||
return [{ kind: "stderr", ts, text: text || "error" }];
|
||||
}
|
||||
return [{ kind: "system", ts, text: `system: ${subtype || "event"}` }];
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
return parseAssistantMessage(parsed.message, ts);
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
return collectTextEntries(parsed.message, ts, "user");
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||
return text ? [{ kind: "thinking", ts, text }] : [];
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
return parseTopLevelToolEvent(parsed, ts);
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage = readUsage(parsed);
|
||||
const errors = parsed.is_error === true
|
||||
? [errorText(parsed.error ?? parsed.message ?? parsed.result)].filter(Boolean)
|
||||
: [];
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: asString(parsed.result) || asString(parsed.text) || asString(parsed.response),
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cachedTokens: usage.cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
|
||||
subtype: asString(parsed.subtype, "result"),
|
||||
isError: parsed.is_error === true,
|
||||
errors,
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||
return [{ kind: "stderr", ts, text: text || "error" }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# @paperclipai/adapter-opencode-local
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add local OpenCode adapter package with server/UI/CLI modules.
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@agentcompany/adapter-opencode-local",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentcompany/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const data = asRecord(rec.data);
|
||||
const message =
|
||||
asString(rec.message) ||
|
||||
asString(data?.message) ||
|
||||
asString(rec.name) ||
|
||||
"";
|
||||
if (message) return message;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "step_start") {
|
||||
const sessionId = asString(parsed.sessionID);
|
||||
console.log(pc.blue(`step started${sessionId ? ` (session: ${sessionId})` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "reasoning") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
const part = asRecord(parsed.part);
|
||||
const tool = asString(part?.tool, "tool");
|
||||
const callID = asString(part?.callID);
|
||||
const state = asRecord(part?.state);
|
||||
const status = asString(state?.status);
|
||||
const isError = status === "error";
|
||||
const metadata = asRecord(state?.metadata);
|
||||
|
||||
console.log(pc.yellow(`tool_call: ${tool}${callID ? ` (${callID})` : ""}`));
|
||||
|
||||
if (status) {
|
||||
const metaParts = [`status=${status}`];
|
||||
if (metadata) {
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (value !== undefined && value !== null) metaParts.push(`${key}=${value}`);
|
||||
}
|
||||
}
|
||||
console.log((isError ? pc.red : pc.gray)(`tool_result ${metaParts.join(" ")}`));
|
||||
}
|
||||
|
||||
const output = (asString(state?.output) || asString(state?.error)).trim();
|
||||
if (output) console.log((isError ? pc.red : pc.gray)(output));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = asRecord(parsed.part);
|
||||
const tokens = asRecord(part?.tokens);
|
||||
const cache = asRecord(tokens?.cache);
|
||||
const input = asNumber(tokens?.input, 0);
|
||||
const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0);
|
||||
const cached = asNumber(cache?.read, 0);
|
||||
const cost = asNumber(part?.cost, 0);
|
||||
const reason = asString(part?.reason, "step");
|
||||
console.log(pc.blue(`step finished: reason=${reason}`));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = errorText(parsed.error ?? parsed.message);
|
||||
if (message) console.log(pc.red(`error: ${message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printOpenCodeStreamEvent } from "./format-event.js";
|
||||
@@ -0,0 +1,40 @@
|
||||
export const type = "opencode_local";
|
||||
export const label = "OpenCode (local)";
|
||||
|
||||
export const models: Array<{ id: string; label: string }> = [];
|
||||
|
||||
export const agentConfigurationDoc = `# opencode_local agent configuration
|
||||
|
||||
Adapter: opencode_local
|
||||
|
||||
Use when:
|
||||
- You want AgentCompany to run OpenCode locally as the agent runtime
|
||||
- You want provider/model routing in OpenCode format (provider/model)
|
||||
- You want OpenCode session resume across heartbeats via --session
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use openclaw_gateway or http)
|
||||
- You only need one-shot shell commands (use process)
|
||||
- OpenCode CLI is not installed on the machine
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
|
||||
- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max)
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- command (string, optional): defaults to "opencode"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- OpenCode supports multiple providers and models. Use \
|
||||
\`opencode models\` to list available options in provider/model format.
|
||||
- AgentCompany requires an explicit \`model\` value for \`opencode_local\` agents.
|
||||
- Runs are executed with: opencode run --format json ...
|
||||
- Sessions are resumed with --session when stored session cwd matches current cwd.
|
||||
`;
|
||||
@@ -0,0 +1,370 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
buildAgentCompanyEnv,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"),
|
||||
path.resolve(__moduleDir, "../../../../../skills"),
|
||||
];
|
||||
|
||||
function 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();
|
||||
if (!trimmed.includes("/")) return null;
|
||||
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
|
||||
}
|
||||
|
||||
function claudeSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".claude", "skills");
|
||||
}
|
||||
|
||||
async function resolveAgentCompanySkillsDir(): Promise<string | null> {
|
||||
for (const candidate of SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureOpenCodeSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
|
||||
const skillsDir = await resolveAgentCompanySkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
const skillsHome = claudeSkillsHome();
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) continue;
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Injected OpenCode skill "${entry.name}" into ${skillsHome}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to inject OpenCode skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"{{context.taskTitle}}\n\n{{context.taskDescription}}",
|
||||
);
|
||||
const command = asString(config.command, "opencode");
|
||||
const model = asString(config.model, "").trim();
|
||||
const variant = asString(config.variant, "").trim();
|
||||
|
||||
const workspaceContext = parseObject(context.agentCompanyWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceHints = Array.isArray(context.agentCompanyWorkspaces)
|
||||
? context.agentCompanyWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureOpenCodeSkillsInjected(onLog);
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.AGENT_COMPANY_API_KEY === "string" && envConfig.AGENT_COMPANY_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildAgentCompanyEnv(agent) };
|
||||
env.AGENT_COMPANY_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
if (wakeTaskId) env.AGENT_COMPANY_TASK_ID = wakeTaskId;
|
||||
if (wakeReason) env.AGENT_COMPANY_WAKE_REASON = wakeReason;
|
||||
if (wakeCommentId) env.AGENT_COMPANY_WAKE_COMMENT_ID = wakeCommentId;
|
||||
if (approvalId) env.AGENT_COMPANY_APPROVAL_ID = approvalId;
|
||||
if (approvalStatus) env.AGENT_COMPANY_APPROVAL_STATUS = approvalStatus;
|
||||
if (linkedIssueIds.length > 0) env.AGENT_COMPANY_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (effectiveWorkspaceCwd) env.AGENT_COMPANY_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
if (workspaceSource) env.AGENT_COMPANY_WORKSPACE_SOURCE = workspaceSource;
|
||||
if (workspaceId) env.AGENT_COMPANY_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.AGENT_COMPANY_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.AGENT_COMPANY_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (workspaceHints.length > 0) env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.AGENT_COMPANY_API_KEY = authToken;
|
||||
}
|
||||
const runtimeEnv = Object.fromEntries(
|
||||
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model,
|
||||
command,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const resolvedInstructionsFilePath = instructionsFilePath
|
||||
? path.resolve(cwd, instructionsFilePath)
|
||||
: "";
|
||||
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
if (resolvedInstructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const commandNotes = (() => {
|
||||
if (!resolvedInstructionsFilePath) return [] as string[];
|
||||
if (instructionsPrefix.length > 0) {
|
||||
return [
|
||||
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
|
||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
];
|
||||
})();
|
||||
|
||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
const prompt = `${instructionsPrefix}${renderedPrompt}`;
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["run", "--format", "json"];
|
||||
if (resumeSessionId) args.push("--session", resumeSessionId);
|
||||
if (model) args.push("--model", model);
|
||||
if (variant) args.push("--variant", variant);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
return args;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "opencode_local",
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
stdin: prompt,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
return {
|
||||
proc,
|
||||
rawStderr: proc.stderr,
|
||||
parsed: parseOpenCodeJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const toResult = (
|
||||
attempt: {
|
||||
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
|
||||
rawStderr: string;
|
||||
parsed: ReturnType<typeof parseOpenCodeJsonl>;
|
||||
},
|
||||
clearSessionOnMissingSession = false,
|
||||
): AdapterExecutionResult => {
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSessionId =
|
||||
attempt.parsed.sessionId ??
|
||||
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const rawExitCode = attempt.proc.exitCode;
|
||||
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
stderrLine ||
|
||||
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
|
||||
const modelId = model || null;
|
||||
|
||||
return {
|
||||
exitCode: synthesizedExitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
||||
usage: {
|
||||
inputTokens: attempt.parsed.usage.inputTokens,
|
||||
outputTokens: attempt.parsed.usage.outputTokens,
|
||||
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
|
||||
},
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: parseModelProvider(modelId),
|
||||
model: modelId,
|
||||
billingType: "unknown",
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
resultJson: {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionId);
|
||||
const initialFailed =
|
||||
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
|
||||
if (
|
||||
sessionId &&
|
||||
initialFailed &&
|
||||
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true);
|
||||
}
|
||||
|
||||
return toResult(initial);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AdapterSessionCodec } from "@agentcompany/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId =
|
||||
readNonEmptyString(record.sessionId) ??
|
||||
readNonEmptyString(record.session_id) ??
|
||||
readNonEmptyString(record.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return (
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
listOpenCodeModels,
|
||||
discoverOpenCodeModels,
|
||||
ensureOpenCodeModelConfiguredAndAvailable,
|
||||
resetOpenCodeModelsCacheForTests,
|
||||
} from "./models.js";
|
||||
export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureOpenCodeModelConfiguredAndAvailable,
|
||||
listOpenCodeModels,
|
||||
resetOpenCodeModelsCacheForTests,
|
||||
} from "./models.js";
|
||||
|
||||
describe("openCode models", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.AGENT_COMPANY_OPENCODE_COMMAND;
|
||||
resetOpenCodeModelsCacheForTests();
|
||||
});
|
||||
|
||||
it("returns an empty list when discovery command is unavailable", async () => {
|
||||
process.env.AGENT_COMPANY_OPENCODE_COMMAND = "__agentcompany_missing_opencode_command__";
|
||||
await expect(listOpenCodeModels()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects when model is missing", async () => {
|
||||
await expect(
|
||||
ensureOpenCodeModelConfiguredAndAvailable({ model: "" }),
|
||||
).rejects.toThrow("OpenCode requires `adapterConfig.model`");
|
||||
});
|
||||
|
||||
it("rejects when discovery cannot run for configured model", async () => {
|
||||
process.env.AGENT_COMPANY_OPENCODE_COMMAND = "__agentcompany_missing_opencode_command__";
|
||||
await expect(
|
||||
ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model: "openai/gpt-5",
|
||||
}),
|
||||
).rejects.toThrow("Failed to start command");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AdapterModel } from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
|
||||
const MODELS_CACHE_TTL_MS = 60_000;
|
||||
|
||||
function resolveOpenCodeCommand(input: unknown): string {
|
||||
const envOverride =
|
||||
typeof process.env.AGENT_COMPANY_OPENCODE_COMMAND === "string" &&
|
||||
process.env.AGENT_COMPANY_OPENCODE_COMMAND.trim().length > 0
|
||||
? process.env.AGENT_COMPANY_OPENCODE_COMMAND.trim()
|
||||
: "opencode";
|
||||
return asString(input, envOverride);
|
||||
}
|
||||
|
||||
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
|
||||
const VOLATILE_ENV_KEY_PREFIXES = ["AGENT_COMPANY_", "npm_", "NPM_"] as const;
|
||||
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]);
|
||||
|
||||
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: AdapterModel[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push({ id, label: model.label.trim() || id });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function sortModels(models: AdapterModel[]): AdapterModel[] {
|
||||
return [...models].sort((a, b) =>
|
||||
a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
function 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/)) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const firstToken = line.split(/\s+/)[0]?.trim() ?? "";
|
||||
if (!firstToken.includes("/")) continue;
|
||||
const provider = firstToken.slice(0, firstToken.indexOf("/")).trim();
|
||||
const model = firstToken.slice(firstToken.indexOf("/") + 1).trim();
|
||||
if (!provider || !model) continue;
|
||||
parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` });
|
||||
}
|
||||
return dedupeModels(parsed);
|
||||
}
|
||||
|
||||
function normalizeEnv(input: unknown): Record<string, string> {
|
||||
const envInput = typeof input === "object" && input !== null && !Array.isArray(input)
|
||||
? (input as Record<string, unknown>)
|
||||
: {};
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envInput)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function isVolatileEnvKey(key: string): boolean {
|
||||
if (VOLATILE_ENV_KEY_EXACT.has(key)) return true;
|
||||
return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
function hashValue(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
|
||||
const envKey = Object.entries(env)
|
||||
.filter(([key]) => !isVolatileEnvKey(key))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${hashValue(value)}`)
|
||||
.join("\n");
|
||||
return `${command}\n${cwd}\n${envKey}`;
|
||||
}
|
||||
|
||||
function pruneExpiredDiscoveryCache(now: number) {
|
||||
for (const [key, value] of discoveryCache.entries()) {
|
||||
if (value.expiresAt <= now) discoveryCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverOpenCodeModels(input: {
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
} = {}): Promise<AdapterModel[]> {
|
||||
const command = resolveOpenCodeCommand(input.command);
|
||||
const cwd = asString(input.cwd, process.cwd());
|
||||
const env = normalizeEnv(input.env);
|
||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
|
||||
|
||||
const result = await runChildProcess(
|
||||
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
["models"],
|
||||
{
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec: 20,
|
||||
graceSec: 3,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
if (result.timedOut) {
|
||||
throw new Error("`opencode models` timed out.");
|
||||
}
|
||||
if ((result.exitCode ?? 1) !== 0) {
|
||||
const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout);
|
||||
throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed.");
|
||||
}
|
||||
|
||||
return sortModels(parseModelsOutput(result.stdout));
|
||||
}
|
||||
|
||||
export async function discoverOpenCodeModelsCached(input: {
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
} = {}): Promise<AdapterModel[]> {
|
||||
const command = resolveOpenCodeCommand(input.command);
|
||||
const cwd = asString(input.cwd, process.cwd());
|
||||
const env = normalizeEnv(input.env);
|
||||
const key = discoveryCacheKey(command, cwd, env);
|
||||
const now = Date.now();
|
||||
pruneExpiredDiscoveryCache(now);
|
||||
const cached = discoveryCache.get(key);
|
||||
if (cached && cached.expiresAt > now) return cached.models;
|
||||
|
||||
const models = await discoverOpenCodeModels({ command, cwd, env });
|
||||
discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models });
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function ensureOpenCodeModelConfiguredAndAvailable(input: {
|
||||
model?: unknown;
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
}): Promise<AdapterModel[]> {
|
||||
const model = asString(input.model, "").trim();
|
||||
if (!model) {
|
||||
throw new Error("OpenCode requires `adapterConfig.model` in provider/model format.");
|
||||
}
|
||||
|
||||
const models = await discoverOpenCodeModelsCached({
|
||||
command: input.command,
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
});
|
||||
|
||||
if (models.length === 0) {
|
||||
throw new Error("OpenCode returned no models. Run `opencode models` and verify provider auth.");
|
||||
}
|
||||
|
||||
if (!models.some((entry) => entry.id === model)) {
|
||||
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
|
||||
throw new Error(
|
||||
`Configured OpenCode model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function listOpenCodeModels(): Promise<AdapterModel[]> {
|
||||
try {
|
||||
return await discoverOpenCodeModelsCached();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function resetOpenCodeModelsCacheForTests() {
|
||||
discoveryCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
||||
|
||||
describe("parseOpenCodeJsonl", () => {
|
||||
it("parses assistant text, usage, cost, and errors", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "text",
|
||||
sessionID: "session_123",
|
||||
part: { text: "Hello from OpenCode" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "step_finish",
|
||||
sessionID: "session_123",
|
||||
part: {
|
||||
reason: "done",
|
||||
cost: 0.0025,
|
||||
tokens: {
|
||||
input: 120,
|
||||
output: 40,
|
||||
reasoning: 10,
|
||||
cache: { read: 20, write: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
sessionID: "session_123",
|
||||
error: { message: "model unavailable" },
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseOpenCodeJsonl(stdout);
|
||||
expect(parsed.sessionId).toBe("session_123");
|
||||
expect(parsed.summary).toBe("Hello from OpenCode");
|
||||
expect(parsed.usage).toEqual({
|
||||
inputTokens: 120,
|
||||
cachedInputTokens: 20,
|
||||
outputTokens: 50,
|
||||
});
|
||||
expect(parsed.costUsd).toBeCloseTo(0.0025, 6);
|
||||
expect(parsed.errorMessage).toContain("model unavailable");
|
||||
});
|
||||
|
||||
it("detects unknown session errors", () => {
|
||||
expect(isOpenCodeUnknownSessionError("Session not found: s_123", "")).toBe(true);
|
||||
expect(isOpenCodeUnknownSessionError("", "unknown session id")).toBe(true);
|
||||
expect(isOpenCodeUnknownSessionError("all good", "")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { asNumber, asString, parseJson, parseObject } from "@agentcompany/adapter-utils/server-utils";
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = parseObject(value);
|
||||
const message = asString(rec.message, "").trim();
|
||||
if (message) return message;
|
||||
const data = parseObject(rec.data);
|
||||
const nestedMessage = asString(data.message, "").trim();
|
||||
if (nestedMessage) return nestedMessage;
|
||||
const name = asString(rec.name, "").trim();
|
||||
if (name) return name;
|
||||
const code = asString(rec.code, "").trim();
|
||||
if (code) return code;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOpenCodeJsonl(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
const messages: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
let costUsd = 0;
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const currentSessionId = asString(event.sessionID, "").trim();
|
||||
if (currentSessionId) sessionId = currentSessionId;
|
||||
|
||||
const type = asString(event.type, "");
|
||||
|
||||
if (type === "text") {
|
||||
const part = parseObject(event.part);
|
||||
const text = asString(part.text, "").trim();
|
||||
if (text) messages.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = parseObject(event.part);
|
||||
const tokens = parseObject(part.tokens);
|
||||
const cache = parseObject(tokens.cache);
|
||||
usage.inputTokens += asNumber(tokens.input, 0);
|
||||
usage.cachedInputTokens += asNumber(cache.read, 0);
|
||||
usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0);
|
||||
costUsd += asNumber(part.cost, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
const part = parseObject(event.part);
|
||||
const state = parseObject(part.state);
|
||||
if (asString(state.status, "") === "error") {
|
||||
const text = asString(state.error, "").trim();
|
||||
if (text) errors.push(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = errorText(event.error ?? event.message).trim();
|
||||
if (text) errors.push(text);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
summary: messages.join("\n\n").trim(),
|
||||
usage,
|
||||
costUsd,
|
||||
errorMessage: errors.length > 0 ? errors.join("\n") : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return /unknown\s+session|session\b.*\bnot\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||
import { parseOpenCodeJsonl } from "./parse.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||
if (!raw) return null;
|
||||
const clean = raw.replace(/\s+/g, " ").trim();
|
||||
const max = 240;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean;
|
||||
}
|
||||
|
||||
function normalizeEnv(input: unknown): Record<string, string> {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const OPENCODE_AUTH_REQUIRED_RE =
|
||||
/(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|opencode\s+auth\s+login|free\s+usage\s+exceeded)/i;
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "opencode");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
|
||||
checks.push({
|
||||
code: "opencode_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
|
||||
const openaiKeyOverride = "OPENAI_API_KEY" in envConfig ? asString(envConfig.OPENAI_API_KEY, "") : null;
|
||||
if (openaiKeyOverride !== null && openaiKeyOverride.trim() === "") {
|
||||
checks.push({
|
||||
code: "opencode_openai_api_key_missing",
|
||||
level: "warn",
|
||||
message: "OPENAI_API_KEY override is empty.",
|
||||
hint: "The OPENAI_API_KEY override is empty. Set a valid key or remove the override.",
|
||||
});
|
||||
}
|
||||
|
||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
|
||||
|
||||
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
|
||||
if (cwdInvalid) {
|
||||
checks.push({
|
||||
code: "opencode_command_skipped",
|
||||
level: "warn",
|
||||
message: "Skipped command check because working directory validation failed.",
|
||||
detail: command,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "opencode_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
|
||||
|
||||
let modelValidationPassed = false;
|
||||
const configuredModel = asString(config.model, "").trim();
|
||||
|
||||
if (canRunProbe && configuredModel) {
|
||||
try {
|
||||
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
||||
if (discovered.length > 0) {
|
||||
checks.push({
|
||||
code: "opencode_models_discovered",
|
||||
level: "info",
|
||||
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "opencode_models_empty",
|
||||
level: "error",
|
||||
message: "OpenCode returned no models.",
|
||||
hint: "Run `opencode models` and verify provider authentication.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
if (/ProviderModelNotFoundError/i.test(errMsg)) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_model_unavailable",
|
||||
level: "warn",
|
||||
message: "The configured model was not found by the provider.",
|
||||
detail: errMsg,
|
||||
hint: "Run `opencode models` and choose an available provider/model ID.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "opencode_models_discovery_failed",
|
||||
level: "error",
|
||||
message: errMsg || "OpenCode model discovery failed.",
|
||||
hint: "Run `opencode models` manually to verify provider auth and config.",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (canRunProbe && !configuredModel) {
|
||||
try {
|
||||
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
||||
if (discovered.length > 0) {
|
||||
checks.push({
|
||||
code: "opencode_models_discovered",
|
||||
level: "info",
|
||||
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
if (/ProviderModelNotFoundError/i.test(errMsg)) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_model_unavailable",
|
||||
level: "warn",
|
||||
message: "The configured model was not found by the provider.",
|
||||
detail: errMsg,
|
||||
hint: "Run `opencode models` and choose an available provider/model ID.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "opencode_models_discovery_failed",
|
||||
level: "warn",
|
||||
message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).",
|
||||
hint: "Run `opencode models` manually to verify provider auth and config.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
|
||||
if (!configuredModel && !modelUnavailable) {
|
||||
// No model configured – skip model requirement if no model-related checks exist
|
||||
} else if (configuredModel && canRunProbe) {
|
||||
try {
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model: configuredModel,
|
||||
command,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
checks.push({
|
||||
code: "opencode_model_configured",
|
||||
level: "info",
|
||||
message: `Configured model: ${configuredModel}`,
|
||||
});
|
||||
modelValidationPassed = true;
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_model_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Configured model is unavailable.",
|
||||
hint: "Run `opencode models` and choose a currently available provider/model ID.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (canRunProbe && modelValidationPassed) {
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
const variant = asString(config.variant, "").trim();
|
||||
const probeModel = configuredModel;
|
||||
|
||||
const args = ["run", "--format", "json"];
|
||||
args.push("--model", probeModel);
|
||||
if (variant) args.push("--variant", variant);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
try {
|
||||
const probe = await runChildProcess(
|
||||
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec: 60,
|
||||
graceSec: 5,
|
||||
stdin: "Respond with hello.",
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = parseOpenCodeJsonl(probe.stdout);
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "OpenCode hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) {
|
||||
const summary = parsed.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "OpenCode hello probe succeeded."
|
||||
: "OpenCode probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
|
||||
}),
|
||||
});
|
||||
} else if (/ProviderModelNotFoundError/i.test(authEvidence)) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_model_unavailable",
|
||||
level: "warn",
|
||||
message: "The configured model was not found by the provider.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `opencode models` and choose an available provider/model ID.",
|
||||
});
|
||||
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "OpenCode is installed, but provider authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `opencode auth login` or set provider credentials, then retry the probe.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "OpenCode hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "OpenCode hello probe failed.",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
|
||||
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
|
||||
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 20;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { parseOpenCodeStdoutLine } from "./parse-stdout.js";
|
||||
export { buildOpenCodeLocalConfig } from "./build-config.js";
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { TranscriptEntry } from "@agentcompany/adapter-utils";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const data = asRecord(rec.data);
|
||||
const msg =
|
||||
asString(rec.message) ||
|
||||
asString(data?.message) ||
|
||||
asString(rec.name) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function parseToolUse(parsed: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||
const part = asRecord(parsed.part);
|
||||
if (!part) return [{ kind: "system", ts, text: "tool event" }];
|
||||
|
||||
const toolName = asString(part.tool, "tool");
|
||||
const state = asRecord(part.state);
|
||||
const input = state?.input ?? {};
|
||||
const callEntry: TranscriptEntry = {
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
toolUseId: asString(part.callID) || asString(part.id) || undefined,
|
||||
input,
|
||||
};
|
||||
|
||||
const status = asString(state?.status);
|
||||
if (status !== "completed" && status !== "error") return [callEntry];
|
||||
|
||||
const rawOutput =
|
||||
asString(state?.output) ||
|
||||
asString(state?.error) ||
|
||||
asString(part.title) ||
|
||||
`${toolName} ${status}`;
|
||||
|
||||
const metadata = asRecord(state?.metadata);
|
||||
const headerParts: string[] = [`status: ${status}`];
|
||||
if (metadata) {
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (value !== undefined && value !== null) headerParts.push(`${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
const content = `${headerParts.join("\n")}\n\n${rawOutput}`.trim();
|
||||
|
||||
return [
|
||||
callEntry,
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: asString(part.callID) || asString(part.id, toolName),
|
||||
content,
|
||||
isError: status === "error",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (!text) return [];
|
||||
return [{ kind: "assistant", ts, text }];
|
||||
}
|
||||
|
||||
if (type === "reasoning") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (!text) return [];
|
||||
return [{ kind: "thinking", ts, text }];
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
return parseToolUse(parsed, ts);
|
||||
}
|
||||
|
||||
if (type === "step_start") {
|
||||
const sessionId = asString(parsed.sessionID);
|
||||
return [
|
||||
{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: `step started${sessionId ? ` (${sessionId})` : ""}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = asRecord(parsed.part);
|
||||
const tokens = asRecord(part?.tokens);
|
||||
const cache = asRecord(tokens?.cache);
|
||||
const reason = asString(part?.reason, "step");
|
||||
const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0);
|
||||
return [
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: reason,
|
||||
inputTokens: asNumber(tokens?.input, 0),
|
||||
outputTokens: output,
|
||||
cachedTokens: asNumber(cache?.read, 0),
|
||||
costUsd: asNumber(part?.cost, 0),
|
||||
subtype: reason,
|
||||
isError: false,
|
||||
errors: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message);
|
||||
return [{ kind: "stderr", ts, text: text || line }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
# @paperclipai/adapter-utils
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@agentcompany/adapter-utils",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/*.d.ts",
|
||||
"import": "./dist/*.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type {
|
||||
AdapterAgent,
|
||||
AdapterRuntime,
|
||||
UsageSummary,
|
||||
AdapterBillingType,
|
||||
AdapterRuntimeServiceReport,
|
||||
AdapterExecutionResult,
|
||||
AdapterInvocationMeta,
|
||||
AdapterExecutionContext,
|
||||
AdapterEnvironmentCheckLevel,
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestStatus,
|
||||
AdapterEnvironmentTestResult,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterSessionCodec,
|
||||
AdapterModel,
|
||||
HireApprovedPayload,
|
||||
HireApprovedHookResult,
|
||||
ServerAdapterModule,
|
||||
TranscriptEntry,
|
||||
StdoutLineParser,
|
||||
CLIAdapterModule,
|
||||
CreateConfigValues,
|
||||
} from "./types.js";
|
||||
export {
|
||||
REDACTED_HOME_PATH_USER,
|
||||
redactHomePathUserSegments,
|
||||
redactHomePathUserSegmentsInValue,
|
||||
redactTranscriptEntryPaths,
|
||||
} from "./log-redaction.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { TranscriptEntry } from "./types.js";
|
||||
|
||||
export const REDACTED_HOME_PATH_USER = "[]";
|
||||
|
||||
const HOME_PATH_PATTERNS = [
|
||||
{
|
||||
regex: /\/Users\/[^/\\\s]+/g,
|
||||
replace: `/Users/${REDACTED_HOME_PATH_USER}`,
|
||||
},
|
||||
{
|
||||
regex: /\/home\/[^/\\\s]+/g,
|
||||
replace: `/home/${REDACTED_HOME_PATH_USER}`,
|
||||
},
|
||||
{
|
||||
regex: /([A-Za-z]:\\Users\\)[^\\/\s]+/g,
|
||||
replace: `$1${REDACTED_HOME_PATH_USER}`,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
export function redactHomePathUserSegments(text: string): string {
|
||||
let result = text;
|
||||
for (const pattern of HOME_PATH_PATTERNS) {
|
||||
result = result.replace(pattern.regex, pattern.replace);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function redactHomePathUserSegmentsInValue<T>(value: T): T {
|
||||
if (typeof value === "string") {
|
||||
return redactHomePathUserSegments(value) as T;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redactHomePathUserSegmentsInValue(entry)) as T;
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
redacted[key] = redactHomePathUserSegmentsInValue(entry);
|
||||
}
|
||||
return redacted as T;
|
||||
}
|
||||
|
||||
export function redactTranscriptEntryPaths(entry: TranscriptEntry): TranscriptEntry {
|
||||
switch (entry.kind) {
|
||||
case "assistant":
|
||||
case "thinking":
|
||||
case "user":
|
||||
case "stderr":
|
||||
case "system":
|
||||
case "stdout":
|
||||
return { ...entry, text: redactHomePathUserSegments(entry.text) };
|
||||
case "tool_call":
|
||||
return { ...entry, name: redactHomePathUserSegments(entry.name), input: redactHomePathUserSegmentsInValue(entry.input) };
|
||||
case "tool_result":
|
||||
return { ...entry, content: redactHomePathUserSegments(entry.content) };
|
||||
case "init":
|
||||
return {
|
||||
...entry,
|
||||
model: redactHomePathUserSegments(entry.model),
|
||||
sessionId: redactHomePathUserSegments(entry.sessionId),
|
||||
};
|
||||
case "result":
|
||||
return {
|
||||
...entry,
|
||||
text: redactHomePathUserSegments(entry.text),
|
||||
subtype: redactHomePathUserSegments(entry.subtype),
|
||||
errors: entry.errors.map((error) => redactHomePathUserSegments(error)),
|
||||
};
|
||||
default:
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { constants as fsConstants, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface RunProcessResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
interface RunningProcess {
|
||||
child: ChildProcess;
|
||||
graceSec: number;
|
||||
}
|
||||
|
||||
interface SpawnTarget {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
type ChildProcessWithEvents = ChildProcess & {
|
||||
on(event: "error", listener: (err: Error) => void): ChildProcess;
|
||||
on(
|
||||
event: "close",
|
||||
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
||||
): ChildProcess;
|
||||
};
|
||||
|
||||
export const runningProcesses = new Map<string, RunningProcess>();
|
||||
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
||||
export const MAX_EXCERPT_BYTES = 32 * 1024;
|
||||
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
|
||||
|
||||
export function parseObject(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function asString(value: unknown, fallback: string): string {
|
||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function asNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
export function asBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
export function parseJson(value: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(value) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) {
|
||||
const combined = prev + chunk;
|
||||
return combined.length > cap ? combined.slice(combined.length - cap) : combined;
|
||||
}
|
||||
|
||||
export function resolvePathValue(obj: Record<string, unknown>, dottedPath: string) {
|
||||
const parts = dottedPath.split(".");
|
||||
let cursor: unknown = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) {
|
||||
return "";
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[part];
|
||||
}
|
||||
|
||||
if (cursor === null || cursor === undefined) return "";
|
||||
if (typeof cursor === "string") return cursor;
|
||||
if (typeof cursor === "number" || typeof cursor === "boolean") return String(cursor);
|
||||
|
||||
try {
|
||||
return JSON.stringify(cursor);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function renderTemplate(template: string, data: Record<string, unknown>) {
|
||||
return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path));
|
||||
}
|
||||
|
||||
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
|
||||
const redacted: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "***REDACTED***" : value;
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export function buildAgentCompanyEnv(agent: { id: string; companyId: string }): Record<string, string> {
|
||||
const resolveHostForUrl = (rawHost: string): string => {
|
||||
const host = rawHost.trim();
|
||||
if (!host || host === "0.0.0.0" || host === "::") return "localhost";
|
||||
if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`;
|
||||
return host;
|
||||
};
|
||||
const vars: Record<string, string> = {
|
||||
AGENT_COMPANY_AGENT_ID: agent.id,
|
||||
AGENT_COMPANY_COMPANY_ID: agent.companyId,
|
||||
};
|
||||
const runtimeHost = resolveHostForUrl(
|
||||
process.env.AGENT_COMPANY_LISTEN_HOST ?? process.env.HOST ?? "localhost",
|
||||
);
|
||||
const runtimePort = process.env.AGENT_COMPANY_LISTEN_PORT ?? process.env.PORT ?? "3100";
|
||||
const apiUrl = process.env.AGENT_COMPANY_API_URL ?? `http://${runtimeHost}:${runtimePort}`;
|
||||
vars.AGENT_COMPANY_API_URL = apiUrl;
|
||||
return vars;
|
||||
}
|
||||
|
||||
export function defaultPathForPlatform() {
|
||||
if (process.platform === "win32") {
|
||||
return "C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem";
|
||||
}
|
||||
return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
}
|
||||
|
||||
function windowsPathExts(env: NodeJS.ProcessEnv): string[] {
|
||||
return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
|
||||
}
|
||||
|
||||
async function pathExists(candidate: string) {
|
||||
try {
|
||||
await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise<string | null> {
|
||||
const hasPathSeparator = command.includes("/") || command.includes("\\");
|
||||
if (hasPathSeparator) {
|
||||
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
|
||||
return (await pathExists(absolute)) ? absolute : null;
|
||||
}
|
||||
|
||||
const pathValue = env.PATH ?? env.Path ?? "";
|
||||
const delimiter = process.platform === "win32" ? ";" : ":";
|
||||
const dirs = pathValue.split(delimiter).filter(Boolean);
|
||||
const exts = process.platform === "win32" ? windowsPathExts(env) : [""];
|
||||
const hasExtension = process.platform === "win32" && path.extname(command).length > 0;
|
||||
|
||||
for (const dir of dirs) {
|
||||
const candidates =
|
||||
process.platform === "win32"
|
||||
? hasExtension
|
||||
? [path.join(dir, command)]
|
||||
: exts.map((ext) => path.join(dir, `${command}${ext}`))
|
||||
: [path.join(dir, command)];
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExists(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function quoteForCmd(arg: string) {
|
||||
if (!arg.length) return '""';
|
||||
const escaped = arg.replace(/"/g, '""');
|
||||
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
|
||||
}
|
||||
|
||||
async function resolveSpawnTarget(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<SpawnTarget> {
|
||||
const resolved = await resolveCommandPath(command, cwd, env);
|
||||
const executable = resolved ?? command;
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
if (/\.(cmd|bat)$/i.test(executable)) {
|
||||
const shell = env.ComSpec || process.env.ComSpec || "cmd.exe";
|
||||
const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" ");
|
||||
return {
|
||||
command: shell,
|
||||
args: ["/d", "/s", "/c", commandLine],
|
||||
};
|
||||
}
|
||||
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
if (typeof env.PATH === "string" && env.PATH.length > 0) return env;
|
||||
if (typeof env.Path === "string" && env.Path.length > 0) return env;
|
||||
return { ...env, PATH: defaultPathForPlatform() };
|
||||
}
|
||||
|
||||
export async function ensureAbsoluteDirectory(
|
||||
cwd: string,
|
||||
opts: { createIfMissing?: boolean } = {},
|
||||
) {
|
||||
if (!path.isAbsolute(cwd)) {
|
||||
throw new Error(`Working directory must be an absolute path: "${cwd}"`);
|
||||
}
|
||||
|
||||
const assertDirectory = async () => {
|
||||
const stats = await fs.stat(cwd);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Working directory is not a directory: "${cwd}"`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await assertDirectory();
|
||||
return;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (!opts.createIfMissing || code !== "ENOENT") {
|
||||
if (code === "ENOENT") {
|
||||
throw new Error(`Working directory does not exist: "${cwd}"`);
|
||||
}
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
await assertDirectory();
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Could not create working directory "${cwd}": ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||
const resolved = await resolveCommandPath(command, cwd, env);
|
||||
if (resolved) return;
|
||||
if (command.includes("/") || command.includes("\\")) {
|
||||
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
|
||||
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
|
||||
}
|
||||
throw new Error(`Command not found in PATH: "${command}"`);
|
||||
}
|
||||
|
||||
export async function runChildProcess(
|
||||
runId: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: {
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onLogError?: (err: unknown, runId: string, message: string) => void;
|
||||
stdin?: string;
|
||||
},
|
||||
): Promise<RunProcessResult> {
|
||||
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
|
||||
|
||||
return new Promise<RunProcessResult>((resolve, reject) => {
|
||||
const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env };
|
||||
|
||||
// Strip Claude Code nesting-guard env vars so spawned `claude` processes
|
||||
// don't refuse to start with "cannot be launched inside another session".
|
||||
// These vars leak in when the AgentCompany server itself is started from
|
||||
// within a Claude Code session (e.g. `npx agentcompany run` in a terminal
|
||||
// owned by Claude Code) or when cron inherits a contaminated shell env.
|
||||
const CLAUDE_CODE_NESTING_VARS = [
|
||||
"CLAUDECODE",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_SESSION",
|
||||
"CLAUDE_CODE_PARENT_SESSION",
|
||||
] as const;
|
||||
for (const key of CLAUDE_CODE_NESTING_VARS) {
|
||||
delete rawMerged[key];
|
||||
}
|
||||
|
||||
const mergedEnv = ensurePathInEnv(rawMerged);
|
||||
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv)
|
||||
.then((target) => {
|
||||
const child = spawn(target.command, target.args, {
|
||||
cwd: opts.cwd,
|
||||
env: mergedEnv,
|
||||
shell: false,
|
||||
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||
}) as ChildProcessWithEvents;
|
||||
|
||||
if (opts.stdin != null && child.stdin) {
|
||||
child.stdin.write(opts.stdin);
|
||||
child.stdin.end();
|
||||
}
|
||||
|
||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
||||
|
||||
let timedOut = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let logChain: Promise<void> = Promise.resolve();
|
||||
|
||||
const timeout =
|
||||
opts.timeoutSec > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, Math.max(1, opts.graceSec) * 1000);
|
||||
}, opts.timeoutSec * 1000)
|
||||
: null;
|
||||
|
||||
child.stdout?.on("data", (chunk: unknown) => {
|
||||
const text = String(chunk);
|
||||
stdout = appendWithCap(stdout, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stdout", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: unknown) => {
|
||||
const text = String(chunk);
|
||||
stderr = appendWithCap(stderr, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stderr", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
|
||||
});
|
||||
|
||||
child.on("error", (err: Error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
const errno = (err as NodeJS.ErrnoException).code;
|
||||
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
||||
const msg =
|
||||
errno === "ENOENT"
|
||||
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
|
||||
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
|
||||
reject(new Error(msg));
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
void logChain.finally(() => {
|
||||
resolve({
|
||||
exitCode: code,
|
||||
signal,
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal adapter-facing interfaces (no drizzle dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AdapterAgent {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
adapterType: string | null;
|
||||
adapterConfig: unknown;
|
||||
}
|
||||
|
||||
export interface AdapterRuntime {
|
||||
/**
|
||||
* Legacy single session id view. Prefer `sessionParams` + `sessionDisplayId`.
|
||||
*/
|
||||
sessionId: string | null;
|
||||
sessionParams: Record<string, unknown> | null;
|
||||
sessionDisplayId: string | null;
|
||||
taskKey: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution types (moved from server/src/adapters/types.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UsageSummary {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedInputTokens?: number;
|
||||
}
|
||||
|
||||
export type AdapterBillingType = "api" | "subscription" | "unknown";
|
||||
|
||||
export interface AdapterRuntimeServiceReport {
|
||||
id?: string | null;
|
||||
projectId?: string | null;
|
||||
projectWorkspaceId?: string | null;
|
||||
issueId?: string | null;
|
||||
scopeType?: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||
scopeId?: string | null;
|
||||
serviceName: string;
|
||||
status?: "starting" | "running" | "stopped" | "failed";
|
||||
lifecycle?: "shared" | "ephemeral";
|
||||
reuseKey?: string | null;
|
||||
command?: string | null;
|
||||
cwd?: string | null;
|
||||
port?: number | null;
|
||||
url?: string | null;
|
||||
providerRef?: string | null;
|
||||
ownerAgentId?: string | null;
|
||||
stopPolicy?: Record<string, unknown> | null;
|
||||
healthStatus?: "unknown" | "healthy" | "unhealthy";
|
||||
}
|
||||
|
||||
export interface AdapterExecutionResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
errorMessage?: string | null;
|
||||
errorCode?: string | null;
|
||||
errorMeta?: Record<string, unknown>;
|
||||
usage?: UsageSummary;
|
||||
/**
|
||||
* Legacy single session id output. Prefer `sessionParams` + `sessionDisplayId`.
|
||||
*/
|
||||
sessionId?: string | null;
|
||||
sessionParams?: Record<string, unknown> | null;
|
||||
sessionDisplayId?: string | null;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
billingType?: AdapterBillingType | null;
|
||||
costUsd?: number | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
runtimeServices?: AdapterRuntimeServiceReport[];
|
||||
summary?: string | null;
|
||||
clearSession?: boolean;
|
||||
question?: {
|
||||
prompt: string;
|
||||
choices: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AdapterSessionCodec {
|
||||
deserialize(raw: unknown): Record<string, unknown> | null;
|
||||
serialize(params: Record<string, unknown> | null): Record<string, unknown> | null;
|
||||
getDisplayId?: (params: Record<string, unknown> | null) => string | null;
|
||||
}
|
||||
|
||||
export interface AdapterInvocationMeta {
|
||||
adapterType: string;
|
||||
command: string;
|
||||
cwd?: string;
|
||||
commandArgs?: string[];
|
||||
commandNotes?: string[];
|
||||
env?: Record<string, string>;
|
||||
prompt?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AdapterExecutionContext {
|
||||
runId: string;
|
||||
agent: AdapterAgent;
|
||||
runtime: AdapterRuntime;
|
||||
config: Record<string, unknown>;
|
||||
context: Record<string, unknown>;
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export interface AdapterModel {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type AdapterEnvironmentCheckLevel = "info" | "warn" | "error";
|
||||
|
||||
export interface AdapterEnvironmentCheck {
|
||||
code: string;
|
||||
level: AdapterEnvironmentCheckLevel;
|
||||
message: string;
|
||||
detail?: string | null;
|
||||
hint?: string | null;
|
||||
}
|
||||
|
||||
export type AdapterEnvironmentTestStatus = "pass" | "warn" | "fail";
|
||||
|
||||
export interface AdapterEnvironmentTestResult {
|
||||
adapterType: string;
|
||||
status: AdapterEnvironmentTestStatus;
|
||||
checks: AdapterEnvironmentCheck[];
|
||||
testedAt: string;
|
||||
}
|
||||
|
||||
export interface AdapterEnvironmentTestContext {
|
||||
companyId: string;
|
||||
adapterType: string;
|
||||
config: Record<string, unknown>;
|
||||
deployment?: {
|
||||
mode?: "local_trusted" | "authenticated";
|
||||
exposure?: "private" | "public";
|
||||
bindHost?: string | null;
|
||||
allowedHostnames?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Payload for the onHireApproved adapter lifecycle hook (e.g. join-request or hire_agent approval). */
|
||||
export interface HireApprovedPayload {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
adapterType: string;
|
||||
/** "join_request" | "approval" */
|
||||
source: "join_request" | "approval";
|
||||
sourceId: string;
|
||||
approvedAt: string;
|
||||
/** Canonical operator-facing message for cloud adapters to show the user. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Result of onHireApproved hook; failures are non-fatal to the approval flow. */
|
||||
export interface HireApprovedHookResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ServerAdapterModule {
|
||||
type: string;
|
||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
|
||||
sessionCodec?: AdapterSessionCodec;
|
||||
supportsLocalAgentJwt?: boolean;
|
||||
models?: AdapterModel[];
|
||||
listModels?: () => Promise<AdapterModel[]>;
|
||||
agentConfigurationDoc?: string;
|
||||
/**
|
||||
* Optional lifecycle hook when an agent is approved/hired (join-request or hire_agent approval).
|
||||
* adapterConfig is the agent's adapter config so the adapter can e.g. send a callback to a configured URL.
|
||||
*/
|
||||
onHireApproved?: (
|
||||
payload: HireApprovedPayload,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) => Promise<HireApprovedHookResult>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI types (moved from ui/src/adapters/types.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TranscriptEntry =
|
||||
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
|
||||
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
|
||||
| { kind: "user"; ts: string; text: string }
|
||||
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
|
||||
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
||||
| { kind: "init"; ts: string; model: string; sessionId: string }
|
||||
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
|
||||
| { kind: "stderr"; ts: string; text: string }
|
||||
| { kind: "system"; ts: string; text: string }
|
||||
| { kind: "stdout"; ts: string; text: string };
|
||||
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateConfigValues {
|
||||
adapterType: string;
|
||||
cwd: string;
|
||||
instructionsFilePath?: string;
|
||||
promptTemplate: string;
|
||||
model: string;
|
||||
thinkingEffort: string;
|
||||
chrome: boolean;
|
||||
dangerouslySkipPermissions: boolean;
|
||||
search: boolean;
|
||||
dangerouslyBypassSandbox: boolean;
|
||||
command: string;
|
||||
args: string;
|
||||
extraArgs: string;
|
||||
envVars: string;
|
||||
envBindings: Record<string, unknown>;
|
||||
url: string;
|
||||
bootstrapPrompt: string;
|
||||
payloadTemplateJson?: string;
|
||||
workspaceStrategyType?: string;
|
||||
workspaceBaseRef?: string;
|
||||
workspaceBranchTemplate?: string;
|
||||
worktreeParentDir?: string;
|
||||
runtimeServicesJson?: string;
|
||||
maxTurnsPerRun: number;
|
||||
heartbeatEnabled: boolean;
|
||||
intervalSec: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Generated
+5673
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
@@ -0,0 +1,53 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const extraArgs = process.argv.slice(2);
|
||||
const env = { ...process.env };
|
||||
|
||||
if (env.CSC_IDENTITY_AUTO_DISCOVERY === undefined) {
|
||||
env.CSC_IDENTITY_AUTO_DISCOVERY = "false";
|
||||
}
|
||||
|
||||
const allowedPublishModes = new Set(["always", "never", "onTag", "onTagOrDraft"]);
|
||||
const explicitPublishArg = extraArgs.some((arg) => arg === "--publish" || arg.startsWith("--publish="));
|
||||
const requestedPublishMode = env.AGENTCOMPANY_ELECTRON_PUBLISH?.trim();
|
||||
let publishMode = "never";
|
||||
|
||||
if (requestedPublishMode) {
|
||||
if (!allowedPublishModes.has(requestedPublishMode)) {
|
||||
console.warn(
|
||||
`[build-desktop] Ignoring invalid AGENTCOMPANY_ELECTRON_PUBLISH="${requestedPublishMode}". ` +
|
||||
"Falling back to --publish never.",
|
||||
);
|
||||
} else {
|
||||
publishMode = requestedPublishMode;
|
||||
}
|
||||
}
|
||||
|
||||
const builderArgs = ["exec", "electron-builder", "--config", "electron-builder.yml"];
|
||||
if (!explicitPublishArg) {
|
||||
builderArgs.push("--publish", publishMode);
|
||||
}
|
||||
builderArgs.push(...extraArgs);
|
||||
|
||||
const child = spawn(
|
||||
"pnpm",
|
||||
builderArgs,
|
||||
{
|
||||
stdio: "inherit",
|
||||
env,
|
||||
shell: process.platform === "win32",
|
||||
},
|
||||
);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate AgentCompany app icons (PNG, ICO, ICNS, SVG).
|
||||
|
||||
Design v3b: Bold "A" monogram that doubles as an org-chart hierarchy.
|
||||
Two terracotta legs meet at an apex, with three warm crossbars of
|
||||
decreasing brightness (CEO → Managers → ICs). All tones stay warm.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ASSETS = ROOT / "assets"
|
||||
ICONSET = ASSETS / "icon.iconset"
|
||||
PNG_PATH = ASSETS / "icon.png"
|
||||
ICO_PATH = ASSETS / "icon.ico"
|
||||
ICNS_PATH = ASSETS / "icon.icns"
|
||||
SVG_PATH = ASSETS / "icon.svg"
|
||||
SIZE = 1024
|
||||
|
||||
# ── Brand palette ──────────────────────────────────────────────────────
|
||||
TERRACOTTA = (217, 119, 69) # #d97745
|
||||
SLATE_TOP = (17, 19, 21) # #111315
|
||||
SLATE_BOT = (27, 31, 35) # #1b1f23
|
||||
|
||||
# Bar colours — all warm, decreasing brightness
|
||||
BAR_CEO = (255, 235, 215) # warm cream — brightest
|
||||
BAR_MGR = (235, 180, 140) # warm peach
|
||||
BAR_IC = (190, 140, 105) # muted terracotta
|
||||
|
||||
|
||||
def rgba(r: int, g: int, b: int, a: int = 255) -> tuple[int, int, int, int]:
|
||||
return (r, g, b, a)
|
||||
|
||||
|
||||
def blend(
|
||||
a: tuple[int, ...], b: tuple[int, ...], t: float,
|
||||
) -> tuple[int, ...]:
|
||||
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(len(a)))
|
||||
|
||||
|
||||
def build_icon() -> Image.Image:
|
||||
# ── 1. Gradient background with warm glow ──────────────────────────
|
||||
bg = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
px = bg.load()
|
||||
top_c = rgba(*SLATE_TOP)
|
||||
bot_c = rgba(*SLATE_BOT)
|
||||
glow_cx, glow_cy = SIZE * 0.44, SIZE * 0.34
|
||||
glow_r = SIZE * 0.78
|
||||
|
||||
for y in range(SIZE):
|
||||
t = y / (SIZE - 1)
|
||||
base = blend(top_c, bot_c, t)
|
||||
for x in range(SIZE):
|
||||
d = math.hypot(x - glow_cx, y - glow_cy) / glow_r
|
||||
warmth = max(0.0, 1.0 - d) ** 1.8 * 0.13
|
||||
px[x, y] = blend(base, rgba(*TERRACOTTA), warmth)
|
||||
|
||||
# ── 2. Rounded-rect mask + shadow ──────────────────────────────────
|
||||
icon = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
mask = Image.new("L", (SIZE, SIZE), 0)
|
||||
inset = 64
|
||||
ImageDraw.Draw(mask).rounded_rectangle(
|
||||
(inset, inset, SIZE - inset, SIZE - inset), radius=224, fill=255,
|
||||
)
|
||||
shadow_alpha = mask.filter(ImageFilter.GaussianBlur(28))
|
||||
shadow_layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 48))
|
||||
shadow_layer.putalpha(shadow_alpha)
|
||||
icon.alpha_composite(shadow_layer, (0, 12))
|
||||
icon.alpha_composite(bg)
|
||||
icon.putalpha(mask)
|
||||
|
||||
# ── 3. Draw the "A" mark ───────────────────────────────────────────
|
||||
overlay = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
|
||||
cx = SIZE // 2
|
||||
apex_y = 200
|
||||
base_y = 800
|
||||
leg_w = 64
|
||||
base_half = 250
|
||||
|
||||
def leg_x(y: int) -> tuple[float, float]:
|
||||
t = (y - apex_y) / (base_y - apex_y)
|
||||
spread = base_half * t
|
||||
return (cx - spread, cx + spread)
|
||||
|
||||
ll_top, _ = leg_x(apex_y)
|
||||
ll_bot, _ = leg_x(base_y)
|
||||
_, lr_top = leg_x(apex_y)
|
||||
_, lr_bot = leg_x(base_y)
|
||||
|
||||
left_leg = [
|
||||
(ll_top - leg_w * 0.35, apex_y),
|
||||
(ll_top + leg_w * 0.35, apex_y),
|
||||
(ll_bot + leg_w, base_y),
|
||||
(ll_bot - leg_w, base_y),
|
||||
]
|
||||
right_leg = [
|
||||
(lr_top - leg_w * 0.35, apex_y),
|
||||
(lr_top + leg_w * 0.35, apex_y),
|
||||
(lr_bot + leg_w, base_y),
|
||||
(lr_bot - leg_w, base_y),
|
||||
]
|
||||
|
||||
draw.polygon(left_leg, fill=rgba(*TERRACOTTA, 240))
|
||||
draw.polygon(right_leg, fill=rgba(*TERRACOTTA, 240))
|
||||
|
||||
# ── 4. Three crossbars — warm tones, hierarchy brightness ──────────
|
||||
bar_h = 42
|
||||
bar_r = 12
|
||||
bar_pad = 14
|
||||
|
||||
tiers = [
|
||||
(440, rgba(*BAR_CEO)),
|
||||
(545, rgba(*BAR_MGR)),
|
||||
(650, rgba(*BAR_IC)),
|
||||
]
|
||||
|
||||
for bar_cy, fill in tiers:
|
||||
left_cx, right_cx = leg_x(bar_cy)
|
||||
bx0 = int(left_cx + bar_pad)
|
||||
bx1 = int(right_cx - bar_pad)
|
||||
draw.rounded_rectangle(
|
||||
(bx0, bar_cy - bar_h // 2, bx1, bar_cy + bar_h // 2),
|
||||
radius=bar_r,
|
||||
fill=fill,
|
||||
)
|
||||
|
||||
# ── 5. Apex beacon ─────────────────────────────────────────────────
|
||||
dot_r = 20
|
||||
dot_cy = apex_y - 2
|
||||
draw.ellipse(
|
||||
(cx - dot_r, dot_cy - dot_r, cx + dot_r, dot_cy + dot_r),
|
||||
fill=rgba(*BAR_CEO),
|
||||
)
|
||||
|
||||
icon.alpha_composite(overlay)
|
||||
return icon
|
||||
|
||||
|
||||
def save_svg() -> None:
|
||||
svg = """\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="200" x2="824" y1="96" y2="928" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#111315"/>
|
||||
<stop offset="1" stop-color="#1b1f23"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0" cy="0" r="1"
|
||||
gradientTransform="translate(450 348) rotate(36) scale(800)" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#d97745" stop-opacity=".13"/>
|
||||
<stop offset="1" stop-color="#d97745" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="896" height="896" x="64" y="64" rx="224" fill="url(#bg)"/>
|
||||
<rect width="896" height="896" x="64" y="64" rx="224" fill="url(#glow)"/>
|
||||
<polygon points="490,200 534,200 578,800 298,800" fill="#d97745" fill-opacity=".94"/>
|
||||
<polygon points="490,200 534,200 726,800 446,800" fill="#d97745" fill-opacity=".94"/>
|
||||
<rect x="400" y="419" width="224" height="42" rx="12" fill="#ffebd7"/>
|
||||
<rect x="374" y="524" width="276" height="42" rx="12" fill="#ebb48c"/>
|
||||
<rect x="348" y="629" width="328" height="42" rx="12" fill="#be8c69"/>
|
||||
<circle cx="512" cy="198" r="20" fill="#ffebd7"/>
|
||||
</svg>
|
||||
"""
|
||||
SVG_PATH.write_text(svg, encoding="utf-8")
|
||||
|
||||
|
||||
def save_png_and_ico(image: Image.Image) -> None:
|
||||
ASSETS.mkdir(parents=True, exist_ok=True)
|
||||
image.save(PNG_PATH, format="PNG")
|
||||
image.save(
|
||||
ICO_PATH,
|
||||
format="ICO",
|
||||
sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
|
||||
)
|
||||
|
||||
|
||||
def save_icns(image: Image.Image) -> None:
|
||||
if ICONSET.exists():
|
||||
shutil.rmtree(ICONSET)
|
||||
ICONSET.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for size in (16, 32, 64, 128, 256, 512):
|
||||
image.resize((size, size), Image.Resampling.LANCZOS).save(
|
||||
ICONSET / f"icon_{size}x{size}.png", format="PNG",
|
||||
)
|
||||
image.resize((size * 2, size * 2), Image.Resampling.LANCZOS).save(
|
||||
ICONSET / f"icon_{size}x{size}@2x.png", format="PNG",
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["iconutil", "-c", "icns", str(ICONSET), "-o", str(ICNS_PATH)],
|
||||
check=True,
|
||||
)
|
||||
shutil.rmtree(ICONSET)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ASSETS.mkdir(parents=True, exist_ok=True)
|
||||
save_svg()
|
||||
image = build_icon()
|
||||
save_png_and_ico(image)
|
||||
save_icns(image)
|
||||
print(f"Generated: {PNG_PATH}, {ICO_PATH}, {ICNS_PATH}, {SVG_PATH}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Patch the Electron.app bundle name so macOS shows "AgentCompany"
|
||||
# in the dock and menu bar during development.
|
||||
# This runs as a postinstall hook and is safe to re-run.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_NAME="AgentCompany"
|
||||
|
||||
# Find the Electron.app Info.plist (works with pnpm hoisted or nested)
|
||||
PLIST=$(find node_modules -path "*/electron/dist/Electron.app/Contents/Info.plist" -maxdepth 8 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$PLIST" ]; then
|
||||
echo "[patch-electron-name] Electron.app not found, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Also find helper plists
|
||||
HELPERS_DIR=$(dirname "$PLIST")/../Frameworks
|
||||
MAIN_PLIST="$PLIST"
|
||||
|
||||
echo "[patch-electron-name] Patching $MAIN_PLIST"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$MAIN_PLIST" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $APP_NAME" "$MAIN_PLIST" 2>/dev/null || true
|
||||
|
||||
# Patch helper apps too (they show in Activity Monitor)
|
||||
for HELPER in "$HELPERS_DIR"/Electron\ Helper*.app/Contents/Info.plist; do
|
||||
if [ -f "$HELPER" ]; then
|
||||
HELPER_NAME=$(basename "$(dirname "$(dirname "$HELPER")")" .app | sed "s/Electron Helper/$APP_NAME Helper/")
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName $HELPER_NAME" "$HELPER" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[patch-electron-name] Done — dock and menu bar will show \"$APP_NAME\"."
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Seed script: Create a multi-agent Research Company with 8 specialized roles.
|
||||
*
|
||||
* Usage: Run via the AgentCompany API after creating a company and workspace.
|
||||
*
|
||||
* Team Structure:
|
||||
* ProjectManager (CEO — reports to Board)
|
||||
* ├── WebResearcher (IC — primary data gathering)
|
||||
* ├── DataAnalyst (IC — market data synthesis)
|
||||
* ├── ReportArchitect (IC — report writing)
|
||||
* ├── VisualDesigner (IC — presentation design)
|
||||
* ├── FactChecker (IC — source verification)
|
||||
* ├── EnterpriseArchitect (Manager — standards guardian)
|
||||
* │ └── SecurityAuditor (IC — ethics & privacy)
|
||||
* └── (all other ICs report to ProjectManager)
|
||||
*/
|
||||
|
||||
interface AgentDefinition {
|
||||
name: string;
|
||||
role: string;
|
||||
title: string;
|
||||
capabilities: string;
|
||||
reportsTo: string | null; // name of manager, null = CEO
|
||||
connectorId: string;
|
||||
budgetMonthlyUsd: number;
|
||||
}
|
||||
|
||||
const RESEARCH_TEAM: AgentDefinition[] = [
|
||||
{
|
||||
name: "ProjectManager",
|
||||
role: "Research Project Coordinator",
|
||||
title: "CEO — Research Operations",
|
||||
capabilities: [
|
||||
"Review research brief/PRD first.",
|
||||
"Define research objectives, target markets (geographic/demographic), and key questions to answer.",
|
||||
"Create research timeline with milestones (data collection → analysis → report writing).",
|
||||
"Ensure compliance with data privacy regulations (GDPR, CCPA) when collecting public data.",
|
||||
"Define deliverable formats (PDF report, presentation deck, raw data spreadsheet).",
|
||||
"Set authority thresholds (peer-reviewed only, or include industry reports).",
|
||||
"Tools: File system, Documentation viewer, Project management board.",
|
||||
].join(" "),
|
||||
reportsTo: null, // Top-level executive
|
||||
connectorId: "claude_local",
|
||||
budgetMonthlyUsd: 500,
|
||||
},
|
||||
{
|
||||
name: "WebResearcher",
|
||||
role: "Agent Browser Research Specialist",
|
||||
title: "Senior Research Analyst",
|
||||
capabilities: [
|
||||
"PRIMARY DATA GATHERING ROLE.",
|
||||
"Use Agent Browser extensively to gather market data.",
|
||||
"Research competitor websites (feature comparison, pricing pages, positioning statements).",
|
||||
"Search industry reports (Gartner, McKinsey, Statista, IBISWorld).",
|
||||
"Scrape public financial data (earnings reports, SEC 10-K filings, Crunchbase funding data).",
|
||||
"Research social media sentiment (Reddit discussions, LinkedIn professional opinions, Twitter/X trends).",
|
||||
"Find authoritative sources only (.edu, .gov, established publications like WSJ, Reuters).",
|
||||
"Archive all sources with URLs, access dates, and credibility ratings.",
|
||||
"Use advanced search operators (site:, filetype:, intitle:) for precision.",
|
||||
"Never paywall-bypass illegally.",
|
||||
"Tools: Agent Browser, Web scraper (legal/ethical only), Documentation library, Archive.org.",
|
||||
].join(" "),
|
||||
reportsTo: "ProjectManager",
|
||||
connectorId: "claude_local",
|
||||
budgetMonthlyUsd: 300,
|
||||
},
|
||||
{
|
||||
name: "DataAnalyst",
|
||||
role: "Market Data Synthesizer",
|
||||
title: "Senior Data Analyst",
|
||||
capabilities: [
|
||||
"Structure collected data into analytical frameworks (SWOT analysis, Porter's Five Forces, TAM/SAM/SOM market sizing).",
|
||||
"Create comparison matrices for competitor analysis (features, pricing, market share).",
|
||||
"Normalize pricing data across different currencies and regions (PPP adjustment).",
|
||||
"Identify market trends and patterns using statistical methods.",
|
||||
"Verify statistical significance of findings (sample sizes, confidence intervals).",
|
||||
"Create data visualizations (Matplotlib/Seaborn charts, Tableau dashboards) from research findings.",
|
||||
"Flag data gaps and limitations.",
|
||||
"Tools: Spreadsheet (Excel/Google Sheets), Data visualization tools, Python (Pandas, Jupyter), Statistical calculator.",
|
||||
].join(" "),
|
||||
reportsTo: "ProjectManager",
|
||||
connectorId: "codex_local",
|
||||
budgetMonthlyUsd: 250,
|
||||
},
|
||||
{
|
||||
name: "ReportArchitect",
|
||||
role: "Research Report Engineer",
|
||||
title: "Lead Technical Writer",
|
||||
capabilities: [
|
||||
"Write comprehensive research reports with clear structure: Executive Summary, Methodology, Key Findings, Competitive Landscape, Market Trends, Risk Analysis, and Strategic Recommendations.",
|
||||
"Ensure actionable insights (not just data dumping).",
|
||||
"Write for specific target audience (C-suite executives need high-level strategy, Product Managers need feature comparisons).",
|
||||
"Maintain consistent citation style throughout (APA 7th edition recommended).",
|
||||
"Include appendices for raw data and detailed methodology.",
|
||||
"Tools: Document editor (Word/Google Docs), Markdown, Citation manager (Zotero/Mendeley).",
|
||||
].join(" "),
|
||||
reportsTo: "ProjectManager",
|
||||
connectorId: "claude_local",
|
||||
budgetMonthlyUsd: 200,
|
||||
},
|
||||
{
|
||||
name: "VisualDesigner",
|
||||
role: "Research Presentation Designer",
|
||||
title: "Visual Design Specialist",
|
||||
capabilities: [
|
||||
"Design professional layouts for research deliverables.",
|
||||
"Create color-coded competitive landscapes (perceptual maps, magic quadrants).",
|
||||
"Design infographics for key statistics and trends.",
|
||||
"Ensure brand consistency in all materials (client fonts, colors, logos).",
|
||||
"Create presentation templates (PowerPoint/Google Slides) with master slides.",
|
||||
"Optimize layouts for readability and visual hierarchy (information density management).",
|
||||
"Ensure accessibility in charts (colorblind-friendly palettes, alt text).",
|
||||
"Tools: Design software (Figma/Adobe Illustrator), Presentation tools (PowerPoint/Keynote), Image preview.",
|
||||
].join(" "),
|
||||
reportsTo: "ProjectManager",
|
||||
connectorId: "gemini_local",
|
||||
budgetMonthlyUsd: 200,
|
||||
},
|
||||
{
|
||||
name: "FactChecker",
|
||||
role: "Source Verification Specialist",
|
||||
title: "Research Integrity Analyst",
|
||||
capabilities: [
|
||||
"Verify all statistics and claims against original primary sources (not secondary citations).",
|
||||
"Check for outdated information (ensure market data is < 2 years old unless historical trend analysis).",
|
||||
"Identify potential biases in sources (vendor-funded research, self-reported data).",
|
||||
"Cross-reference competitor claims with independent third-party validation.",
|
||||
"Ensure all images/charts have proper attribution and licensing.",
|
||||
"Verify calculation accuracy in market sizing (TAM calculations, CAGR formulas).",
|
||||
"Flag uncertain or questionable data with confidence ratings.",
|
||||
"Tools: Agent Browser (re-verification), Documentation library, Archive.org (historical verification).",
|
||||
].join(" "),
|
||||
reportsTo: "ProjectManager",
|
||||
connectorId: "claude_local",
|
||||
budgetMonthlyUsd: 150,
|
||||
},
|
||||
{
|
||||
name: "EnterpriseArchitect",
|
||||
role: "Research Standards Guardian",
|
||||
title: "Head of Research Integrity",
|
||||
capabilities: [
|
||||
"ENFORCE RESEARCH INTEGRITY STANDARDS:",
|
||||
"NO plagiarism — all content must be original synthesis or properly cited with quotation marks for direct quotes.",
|
||||
"NO unsupported claims — every statistic needs inline citation (Author, Year) or footnote.",
|
||||
"File naming convention: YYYY-MM-DD_Topic_Source_Description.pdf.",
|
||||
"Variable naming in datasets: descriptive snake_case (market_share_percent_2024 not ms24).",
|
||||
"NO circular reporting — Wikipedia cannot cite itself, avoid tertiary sources citing each other.",
|
||||
"Primary sources preferred over secondary (SEC filings > News articles about filings).",
|
||||
"Methodology section must be explicit about data collection methods and limitations.",
|
||||
"NO speculation presented as fact — clearly distinguish between 'Data shows X' and 'Analyst suggests Y might happen'.",
|
||||
"Conflict of interest disclosure mandatory (funded by industry player vs independent).",
|
||||
"Tools: Plagiarism checker (Turnitin/Copyscape), Document editor, Reference manager.",
|
||||
].join(" "),
|
||||
reportsTo: "ProjectManager",
|
||||
connectorId: "claude_local",
|
||||
budgetMonthlyUsd: 200,
|
||||
},
|
||||
{
|
||||
name: "SecurityAuditor",
|
||||
role: "Research Ethics & Privacy Auditor",
|
||||
title: "Privacy & Compliance Officer",
|
||||
capabilities: [
|
||||
"Final audit before delivery:",
|
||||
"Verify no private/personal data (PII) was collected without consent (check datasets for names, emails, phone numbers scraped from social media).",
|
||||
"Ensure no proprietary/trade secret information was accessed inappropriately (no hacked data, no insider leaks).",
|
||||
"Check for defamatory content about competitors (stick to verified facts, no opinion-as-fact).",
|
||||
"Verify licensing of third-party images/data (Creative Commons attribution, Fair Use justification documented).",
|
||||
"Ensure confidential client information is redacted if sample reports contain examples.",
|
||||
"Check that web scraping complied with robots.txt and Terms of Service (rate limiting respected).",
|
||||
"Tools: Privacy scanner (PII detection), Document review, Legal compliance checklist.",
|
||||
].join(" "),
|
||||
reportsTo: "EnterpriseArchitect",
|
||||
connectorId: "claude_local",
|
||||
budgetMonthlyUsd: 150,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate curl commands to seed the research team via the AgentCompany API.
|
||||
* Replace $API_URL, $API_KEY, $COMPANY_ID, and $WORKSPACE_ID with real values.
|
||||
*/
|
||||
function generateSetupCommands(): string {
|
||||
const lines: string[] = [
|
||||
"#!/bin/bash",
|
||||
"# Research Company Multi-Agent Team Setup",
|
||||
"# Prerequisites:",
|
||||
"# 1. AgentCompany desktop running with a company and workspace created",
|
||||
"# 2. At least one connector (claude_local) in 'ready' status",
|
||||
"# 3. Set these variables:",
|
||||
"",
|
||||
'API_URL="${AGENT_COMPANY_API_URL:-http://127.0.0.1:3456}"',
|
||||
'API_KEY="${AGENT_COMPANY_API_KEY}"',
|
||||
'COMPANY_ID="${AGENT_COMPANY_COMPANY_ID}"',
|
||||
'WORKSPACE_ID="${AGENT_COMPANY_WORKSPACE_ID}"',
|
||||
"",
|
||||
];
|
||||
|
||||
// Create agents in order (CEO first, then reports)
|
||||
const agentVarMap = new Map<string, string>();
|
||||
|
||||
for (const agent of RESEARCH_TEAM) {
|
||||
const varName = agent.name.toUpperCase() + "_ID";
|
||||
const reportsToVar = agent.reportsTo
|
||||
? agentVarMap.get(agent.reportsTo) ?? "null"
|
||||
: "null";
|
||||
|
||||
agentVarMap.set(agent.name, `$${varName}`);
|
||||
|
||||
lines.push(`# Create ${agent.name} — ${agent.role}`);
|
||||
lines.push(`${varName}=$(curl -s -X POST "$API_URL/api/companies/$COMPANY_ID/agents" \\`);
|
||||
lines.push(` -H "Authorization: Bearer $API_KEY" \\`);
|
||||
lines.push(` -H "Content-Type: application/json" \\`);
|
||||
lines.push(` -d '{`);
|
||||
lines.push(` "name": "${agent.name}",`);
|
||||
lines.push(` "role": "${agent.role}",`);
|
||||
lines.push(` "title": "${agent.title}",`);
|
||||
lines.push(` "connectorId": "${agent.connectorId}",`);
|
||||
lines.push(` "workspaceId": "'$WORKSPACE_ID'",`);
|
||||
lines.push(` "capabilities": ${JSON.stringify(agent.capabilities)},`);
|
||||
lines.push(` "budgetMonthlyUsd": ${agent.budgetMonthlyUsd}${agent.reportsTo ? `,\n "reportsTo": "'${reportsToVar}'"` : ""}`);
|
||||
lines.push(` }' | jq -r '.agentId // .id')`);
|
||||
lines.push(`echo "Created ${agent.name}: $${varName}"`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// Export for use in tests or scripts
|
||||
export { RESEARCH_TEAM, type AgentDefinition };
|
||||
|
||||
// Print setup commands when run directly
|
||||
if (typeof process !== "undefined" && process.argv[1]?.includes("seed-research-team")) {
|
||||
console.log(generateSetupCommands());
|
||||
console.log("\n# Team Structure:");
|
||||
console.log("# ProjectManager (CEO)");
|
||||
console.log("# ├── WebResearcher (IC)");
|
||||
console.log("# ├── DataAnalyst (IC)");
|
||||
console.log("# ├── ReportArchitect (IC)");
|
||||
console.log("# ├── VisualDesigner (IC)");
|
||||
console.log("# ├── FactChecker (IC)");
|
||||
console.log("# └── EnterpriseArchitect (Manager)");
|
||||
console.log("# └── SecurityAuditor (IC)");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Lightweight HMAC-based agent token system.
|
||||
* Tokens are short-lived and scoped to a specific agent + company + run.
|
||||
* No external JWT library needed.
|
||||
*/
|
||||
|
||||
const SIGNING_KEY = randomBytes(32);
|
||||
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000; // 12 hours
|
||||
|
||||
interface TokenPayload {
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
runId: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function sign(payload: TokenPayload): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signature = createHmac("sha256", SIGNING_KEY)
|
||||
.update(`${header}.${body}`)
|
||||
.digest("base64url");
|
||||
return `${header}.${body}.${signature}`;
|
||||
}
|
||||
|
||||
function verify(token: string): TokenPayload | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const [header, body, signature] = parts as [string, string, string];
|
||||
|
||||
const expectedSignature = createHmac("sha256", SIGNING_KEY)
|
||||
.update(`${header}.${body}`)
|
||||
.digest("base64url");
|
||||
|
||||
const sigBuffer = Buffer.from(signature, "base64url");
|
||||
const expectedBuffer = Buffer.from(expectedSignature, "base64url");
|
||||
if (sigBuffer.length !== expectedBuffer.length || !timingSafeEqual(sigBuffer, expectedBuffer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, "base64url").toString()) as TokenPayload;
|
||||
if (Date.now() > payload.exp) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgentToken(agentId: string, companyId: string, runId: string): string {
|
||||
const now = Date.now();
|
||||
return sign({ agentId, companyId, runId, iat: now, exp: now + TOKEN_TTL_MS });
|
||||
}
|
||||
|
||||
export function validateAgentToken(token: string): TokenPayload | null {
|
||||
return verify(token);
|
||||
}
|
||||
|
||||
export type { TokenPayload };
|
||||
@@ -0,0 +1,839 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import log from "electron-log/main.js";
|
||||
import { z } from "zod";
|
||||
import { validateAgentToken, type TokenPayload } from "./agent-auth";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { agentInputSchema, approvalInputSchema, goalInputSchema, projectInputSchema, taskInputSchema } from "@shared/contracts";
|
||||
|
||||
type RequestHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
params: Record<string, string>,
|
||||
token: TokenPayload,
|
||||
body: unknown,
|
||||
) => void | Promise<void>;
|
||||
|
||||
interface Route {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: RequestHandler;
|
||||
}
|
||||
|
||||
const taskUpdateSchema = z.object({
|
||||
status: z.enum(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]).optional(),
|
||||
comment: z.string().min(1).max(4000).optional(),
|
||||
title: z.string().min(2).max(160).optional(),
|
||||
description: z.string().max(4000).optional(),
|
||||
});
|
||||
|
||||
const checkoutSchema = z.object({
|
||||
agentId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const approvalListQuerySchema = z.object({
|
||||
requestedByAgentId: z.string().uuid().optional(),
|
||||
relatedTaskId: z.string().uuid().optional(),
|
||||
status: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Lightweight local HTTP API server for agent-to-system interaction.
|
||||
* Agents call this API during runs to create tasks, request approvals,
|
||||
* post comments, and query their assignments.
|
||||
*/
|
||||
export class AgentApiServer {
|
||||
private routes: Route[] = [];
|
||||
private server: ReturnType<typeof createServer> | null = null;
|
||||
private port = 0;
|
||||
private db: AppDatabase;
|
||||
private onDomainChanged: () => void;
|
||||
private onWakeAgent: (agentId: string, companyId: string, trigger: string) => void;
|
||||
|
||||
constructor(db: AppDatabase, onDomainChanged: () => void, onWakeAgent: (agentId: string, companyId: string, trigger: string) => void) {
|
||||
this.db = db;
|
||||
this.onDomainChanged = onDomainChanged;
|
||||
this.onWakeAgent = onWakeAgent;
|
||||
this.registerRoutes();
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return this.port;
|
||||
}
|
||||
|
||||
async start(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = createServer((req, res) => void this.handleRequest(req, res));
|
||||
this.server.listen(0, "127.0.0.1", () => {
|
||||
const addr = this.server!.address();
|
||||
this.port = typeof addr === "object" && addr ? addr.port : 0;
|
||||
log.info(`[api] Agent API server listening on http://127.0.0.1:${this.port}`);
|
||||
resolve(this.port);
|
||||
});
|
||||
this.server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.server?.close();
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
private addRoute(method: string, path: string, handler: RequestHandler) {
|
||||
const paramNames: string[] = [];
|
||||
const patternStr = path.replace(/:(\w+)/g, (_match, name: string) => {
|
||||
paramNames.push(name);
|
||||
return "([^/]+)";
|
||||
});
|
||||
this.routes.push({ method, pattern: new RegExp(`^${patternStr}$`), paramNames, handler });
|
||||
}
|
||||
|
||||
private async handleRequest(req: IncomingMessage, res: ServerResponse) {
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
const allowedOrigin =
|
||||
origin === "app://-" ||
|
||||
origin.startsWith("http://127.0.0.1:") ||
|
||||
origin.startsWith("http://localhost:");
|
||||
if (!allowedOrigin) {
|
||||
this.json(res, 403, { error: "Origin not allowed" });
|
||||
return;
|
||||
}
|
||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||
res.setHeader("Vary", "Origin");
|
||||
}
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Agent-Company-Run-Id");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
|
||||
const pathname = url.pathname;
|
||||
const method = (req.method ?? "GET").toUpperCase();
|
||||
|
||||
// Auth
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
this.json(res, 401, { error: "Missing or invalid Authorization header" });
|
||||
return;
|
||||
}
|
||||
const token = validateAgentToken(authHeader.slice(7));
|
||||
if (!token) {
|
||||
this.json(res, 401, { error: "Invalid or expired token" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Bind token to its run — verify the run exists and belongs to the token's company
|
||||
if (!this.db.belongsToCompany("runs", token.runId, token.companyId)) {
|
||||
this.json(res, 403, { error: "Token references an unknown or mismatched run" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Match route
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== method) continue;
|
||||
const match = pathname.match(route.pattern);
|
||||
if (!match) continue;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
route.paramNames.forEach((name, i) => {
|
||||
params[name] = match[i + 1]!;
|
||||
});
|
||||
|
||||
// Parse body for POST/PATCH/PUT
|
||||
let body: unknown = null;
|
||||
if (["POST", "PATCH", "PUT"].includes(method)) {
|
||||
try {
|
||||
body = await this.readBody(req);
|
||||
} catch {
|
||||
this.json(res, 400, { error: "Invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await route.handler(req, res, params, token, body);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`[api] ${method} ${pathname}: ${message}`);
|
||||
this.json(res, 500, { error: message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.json(res, 404, { error: "Not found" });
|
||||
}
|
||||
|
||||
private readBody(req: IncomingMessage): Promise<unknown> {
|
||||
const MAX_BODY_BYTES = 1_048_576; // 1 MB
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
let bytes = 0;
|
||||
req.on("data", (chunk) => {
|
||||
bytes += Buffer.byteLength(chunk);
|
||||
if (bytes > MAX_BODY_BYTES) {
|
||||
req.destroy();
|
||||
reject(new Error("Request body too large"));
|
||||
return;
|
||||
}
|
||||
data += String(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
try { resolve(data ? JSON.parse(data) : null); }
|
||||
catch { reject(new Error("Invalid JSON")); }
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
private json(res: ServerResponse, status: number, data: unknown) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
private companyRefError(
|
||||
res: ServerResponse,
|
||||
companyId: string,
|
||||
refs: Array<{ id: string | null | undefined; table: "agents" | "goals" | "projects" | "tasks" | "workspaces" | "approvals"; label: string }>,
|
||||
) {
|
||||
const invalid = refs.find((ref) => ref.id && !this.db.belongsToCompany(ref.table, ref.id, companyId));
|
||||
if (!invalid) return false;
|
||||
this.json(res, 422, { error: `${invalid.label} does not belong to the current company` });
|
||||
return true;
|
||||
}
|
||||
|
||||
private registerRoutes() {
|
||||
// GET /api/agents/me — Agent identity
|
||||
this.addRoute("GET", "/api/agents/me", (_req, res, _params, token) => {
|
||||
const agent = this.db.getAgent(token.agentId);
|
||||
const chainOfCommand = this.db.getChainOfCommand(token.agentId);
|
||||
const directReports = this.db.getDirectReports(token.agentId, token.companyId);
|
||||
this.json(res, 200, { agent, chainOfCommand, directReports });
|
||||
});
|
||||
|
||||
// GET /api/companies/:companyId/issues — List issues with filters
|
||||
this.addRoute("GET", "/api/companies/:companyId/issues", (req, res, params, token) => {
|
||||
const companyId = params.companyId!;
|
||||
if (companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
|
||||
const assigneeAgentId = url.searchParams.get("assigneeAgentId");
|
||||
const statusFilter = url.searchParams.get("status");
|
||||
const statuses = statusFilter ? statusFilter.split(",") : ["todo", "in_progress", "blocked"];
|
||||
|
||||
if (assigneeAgentId) {
|
||||
const tasks = this.db.listAgentTasks(assigneeAgentId, companyId, statuses);
|
||||
this.json(res, 200, tasks);
|
||||
} else {
|
||||
// Return all company tasks with the given statuses
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const tasks = snapshot.tasks.filter((t) => t.companyId === companyId && statuses.includes(t.status));
|
||||
this.json(res, 200, tasks);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/issues/:issueId — Get single issue
|
||||
this.addRoute("GET", "/api/issues/:issueId", (_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;
|
||||
}
|
||||
this.json(res, 200, task);
|
||||
} catch {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/issues/:issueId/checkout — Claim a task
|
||||
this.addRoute("POST", "/api/issues/:issueId/checkout", (_req, res, params, token, body) => {
|
||||
const parsedBody = checkoutSchema.safeParse(body);
|
||||
if (!parsedBody.success) {
|
||||
this.json(res, 400, { error: "Invalid checkout payload" });
|
||||
return;
|
||||
}
|
||||
const agentId = parsedBody.data.agentId ?? token.agentId;
|
||||
if (agentId !== token.agentId) {
|
||||
this.json(res, 403, { error: "Agents can only checkout work for themselves" });
|
||||
return;
|
||||
}
|
||||
let task;
|
||||
try {
|
||||
task = this.db.getTask(params.issueId!);
|
||||
} catch {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
if (task.companyId !== token.companyId) {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
const success = this.db.checkoutTask(params.issueId!, agentId);
|
||||
if (!success) {
|
||||
this.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" });
|
||||
return;
|
||||
}
|
||||
this.onDomainChanged();
|
||||
this.json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
// PATCH /api/issues/:issueId — Update task
|
||||
this.addRoute("PATCH", "/api/issues/:issueId", (_req, res, params, token, body) => {
|
||||
const parsedUpdate = taskUpdateSchema.safeParse(body);
|
||||
if (!parsedUpdate.success) {
|
||||
this.json(res, 400, { error: "Invalid task update payload" });
|
||||
return;
|
||||
}
|
||||
const update = parsedUpdate.data;
|
||||
|
||||
try {
|
||||
const task = this.db.getTask(params.issueId!);
|
||||
if (task.companyId !== token.companyId) {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update fields
|
||||
const input: Record<string, unknown> = {
|
||||
id: task.id,
|
||||
companyId: task.companyId,
|
||||
title: update.title ?? task.title,
|
||||
description: update.description ?? task.description,
|
||||
status: update.status ?? task.status,
|
||||
priority: task.priority,
|
||||
projectId: task.projectId,
|
||||
goalId: task.goalId,
|
||||
parentId: task.parentId,
|
||||
assigneeAgentId: task.assigneeAgentId,
|
||||
workspaceId: task.workspaceId,
|
||||
};
|
||||
this.db.saveTask(input as Parameters<typeof this.db.saveTask>[0]);
|
||||
|
||||
// Add comment if provided
|
||||
if (update.comment) {
|
||||
const agent = this.db.getAgent(token.agentId);
|
||||
this.db.addComment({
|
||||
companyId: task.companyId,
|
||||
taskId: task.id,
|
||||
authorAgentId: token.agentId,
|
||||
authorName: agent.name,
|
||||
body: update.comment,
|
||||
});
|
||||
}
|
||||
|
||||
this.db.addActivity({
|
||||
companyId: task.companyId,
|
||||
actor: token.agentId,
|
||||
action: `task.${update.status ? "status_changed" : "updated"}`,
|
||||
entityType: "task",
|
||||
entityId: task.id,
|
||||
detail: update.comment ?? `Status → ${update.status ?? task.status}`,
|
||||
});
|
||||
|
||||
this.onDomainChanged();
|
||||
|
||||
// Step 5a: Wake propagation on task status change
|
||||
// Completion notification → task tree (parentId → parent assignee)
|
||||
if (update.status && ["done", "in_review"].includes(update.status)) {
|
||||
if (task.parentId) {
|
||||
try {
|
||||
const parentTask = this.db.getTask(task.parentId);
|
||||
if (parentTask.assigneeAgentId && parentTask.assigneeAgentId !== token.agentId) {
|
||||
this.onWakeAgent(parentTask.assigneeAgentId, task.companyId, "subtask_completed");
|
||||
}
|
||||
} catch { /* parent task may have been deleted */ }
|
||||
} else {
|
||||
// Root task (no parent) — fall back to org hierarchy to notify manager
|
||||
const chain = this.db.getChainOfCommand(token.agentId);
|
||||
if (chain.length > 0) {
|
||||
this.onWakeAgent(chain[0].id, task.companyId, "subtask_completed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked escalation → org hierarchy (agent's manager)
|
||||
if (update.status === "blocked") {
|
||||
const chain = this.db.getChainOfCommand(token.agentId);
|
||||
if (chain.length > 0) {
|
||||
this.onWakeAgent(chain[0].id, task.companyId, "report_blocked");
|
||||
}
|
||||
}
|
||||
|
||||
// Review feedback: when a non-assignee (manager) changes status back to
|
||||
// in_progress or todo, wake the assignee so they know to address feedback
|
||||
if (update.status && ["in_progress", "todo"].includes(update.status) &&
|
||||
task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
|
||||
this.onWakeAgent(task.assigneeAgentId, task.companyId, "assignment");
|
||||
}
|
||||
|
||||
// Comment with status change → wake assignee + manager for the comment
|
||||
// Comment-only PATCH (no status change) → also wake assignee + manager
|
||||
if (update.comment) {
|
||||
const wokenForComment = new Set<string>();
|
||||
if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
|
||||
this.onWakeAgent(task.assigneeAgentId, task.companyId, "comment");
|
||||
wokenForComment.add(task.assigneeAgentId);
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
this.json(res, 200, { ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("require an assignee")) {
|
||||
this.json(res, 422, { error: error.message });
|
||||
return;
|
||||
}
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/companies/:companyId/issues — Create task/subtask
|
||||
this.addRoute("POST", "/api/companies/:companyId/issues", (_req, res, params, token, body) => {
|
||||
const companyId = params.companyId!;
|
||||
if (companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const parsed = taskInputSchema.safeParse({
|
||||
companyId,
|
||||
...(typeof body === "object" && body ? body : {}),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
this.json(res, 400, { error: "Invalid task payload" });
|
||||
return;
|
||||
}
|
||||
const input = parsed.data;
|
||||
if (this.companyRefError(res, companyId, [
|
||||
{ id: input.assigneeAgentId, table: "agents", label: "assignee" },
|
||||
{ id: input.parentId, table: "tasks", label: "parent task" },
|
||||
{ id: input.projectId, table: "projects", label: "project" },
|
||||
{ id: input.goalId, table: "goals", label: "goal" },
|
||||
{ id: input.workspaceId, table: "workspaces", label: "workspace" },
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
let id: string;
|
||||
try {
|
||||
id = this.db.saveTask({
|
||||
companyId,
|
||||
title: input.title,
|
||||
description: input.description ?? "",
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
parentId: input.parentId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
goalId: input.goalId ?? null,
|
||||
priority: input.priority,
|
||||
status: input.status,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
this.json(res, 422, { error: error instanceof Error ? error.message : "Task could not be saved" });
|
||||
return;
|
||||
}
|
||||
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
actor: token.agentId,
|
||||
action: "task.created",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
detail: input.title,
|
||||
});
|
||||
|
||||
this.onDomainChanged();
|
||||
|
||||
// Auto-cascade: when a task is assigned to an agent, enable their heartbeat and wake them
|
||||
if (input.assigneeAgentId && input.assigneeAgentId !== token.agentId) {
|
||||
try {
|
||||
const assignee = this.db.getAgent(input.assigneeAgentId);
|
||||
if (!assignee.heartbeatEnabled) {
|
||||
this.db.setHeartbeat(input.assigneeAgentId, true, assignee.heartbeatIntervalSec || 120);
|
||||
}
|
||||
this.onWakeAgent(input.assigneeAgentId, companyId, "assignment");
|
||||
} catch { /* ignore if agent not found */ }
|
||||
}
|
||||
|
||||
this.json(res, 201, { id });
|
||||
});
|
||||
|
||||
// POST /api/companies/:companyId/projects — Create project
|
||||
this.addRoute("POST", "/api/companies/:companyId/projects", (_req, res, params, token, body) => {
|
||||
const companyId = params.companyId!;
|
||||
if (companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const parsed = projectInputSchema.safeParse({
|
||||
companyId,
|
||||
...(typeof body === "object" && body ? body : {}),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
this.json(res, 400, { error: "Invalid project payload" });
|
||||
return;
|
||||
}
|
||||
const input = parsed.data;
|
||||
if (this.companyRefError(res, companyId, [
|
||||
{ id: input.goalId, table: "goals", label: "goal" },
|
||||
{ id: input.leadAgentId, table: "agents", label: "lead agent" },
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.db.saveProject({
|
||||
companyId,
|
||||
name: input.name,
|
||||
description: input.description ?? "",
|
||||
goalId: input.goalId ?? null,
|
||||
leadAgentId: input.leadAgentId ?? null,
|
||||
status: (input.status as "planned") ?? "planned",
|
||||
targetDate: input.targetDate ?? null,
|
||||
});
|
||||
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
actor: token.agentId,
|
||||
action: "project.created",
|
||||
entityType: "project",
|
||||
entityId: id,
|
||||
detail: input.name,
|
||||
});
|
||||
|
||||
this.onDomainChanged();
|
||||
if (input.leadAgentId && input.leadAgentId !== token.agentId) {
|
||||
try {
|
||||
const lead = this.db.getAgent(input.leadAgentId);
|
||||
if (!lead.heartbeatEnabled) {
|
||||
this.db.setHeartbeat(input.leadAgentId, true, lead.heartbeatIntervalSec || 120);
|
||||
}
|
||||
this.onWakeAgent(input.leadAgentId, companyId, "assignment");
|
||||
} catch {
|
||||
// Ignore missing lead agents after validation drift.
|
||||
}
|
||||
}
|
||||
this.json(res, 201, { id });
|
||||
});
|
||||
|
||||
// POST /api/companies/:companyId/goals — Create goal
|
||||
this.addRoute("POST", "/api/companies/:companyId/goals", (_req, res, params, token, body) => {
|
||||
const companyId = params.companyId!;
|
||||
if (companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const parsed = goalInputSchema.safeParse({
|
||||
companyId,
|
||||
...(typeof body === "object" && body ? body : {}),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
this.json(res, 400, { error: "Invalid goal payload" });
|
||||
return;
|
||||
}
|
||||
const input = parsed.data;
|
||||
if (this.companyRefError(res, companyId, [
|
||||
{ id: input.parentId, table: "goals", label: "parent goal" },
|
||||
{ id: input.ownerAgentId, table: "agents", label: "owner agent" },
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.db.saveGoal(input);
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
actor: token.agentId,
|
||||
action: "goal.created",
|
||||
entityType: "goal",
|
||||
entityId: id,
|
||||
detail: input.title,
|
||||
});
|
||||
this.onDomainChanged();
|
||||
if (input.ownerAgentId && input.ownerAgentId !== token.agentId && input.status === "active") {
|
||||
try {
|
||||
const owner = this.db.getAgent(input.ownerAgentId);
|
||||
if (!owner.heartbeatEnabled) {
|
||||
this.db.setHeartbeat(input.ownerAgentId, true, owner.heartbeatIntervalSec || 120);
|
||||
}
|
||||
this.onWakeAgent(input.ownerAgentId, companyId, "goal_activated");
|
||||
} catch {
|
||||
// Ignore missing owner agents after validation drift.
|
||||
}
|
||||
}
|
||||
this.json(res, 201, { id });
|
||||
});
|
||||
|
||||
// POST /api/companies/:companyId/agents — Propose a hire
|
||||
this.addRoute("POST", "/api/companies/:companyId/agents", (_req, res, params, token, body) => {
|
||||
const companyId = params.companyId!;
|
||||
if (companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const parsed = agentInputSchema.safeParse({
|
||||
companyId,
|
||||
status: "pending_approval",
|
||||
...(typeof body === "object" && body ? body : {}),
|
||||
reportsTo:
|
||||
typeof body === "object" && body && "reportsTo" in body
|
||||
? (body as { reportsTo?: string | null }).reportsTo
|
||||
: token.agentId,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
this.json(res, 400, { error: "Invalid hire payload" });
|
||||
return;
|
||||
}
|
||||
const input = parsed.data;
|
||||
if (this.companyRefError(res, companyId, [
|
||||
{ id: input.reportsTo, table: "agents", label: "manager" },
|
||||
{ id: input.workspaceId, table: "workspaces", label: "workspace" },
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
let agentId: string;
|
||||
try {
|
||||
agentId = this.db.saveAgent({
|
||||
id: input.id ?? null,
|
||||
companyId,
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
title: input.title,
|
||||
status: "pending_approval",
|
||||
reportsTo: input.reportsTo ?? token.agentId,
|
||||
connectorId: input.connectorId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
model: input.model ?? null,
|
||||
metadataJson: input.metadataJson,
|
||||
capabilities: input.capabilities,
|
||||
budgetMonthlyUsd: input.budgetMonthlyUsd,
|
||||
});
|
||||
} catch (error) {
|
||||
this.json(res, 422, { error: error instanceof Error ? error.message : "Hire could not be created" });
|
||||
return;
|
||||
}
|
||||
|
||||
const approvalId = this.db.requestApproval({
|
||||
companyId,
|
||||
requestedByAgentId: token.agentId,
|
||||
type: "hire_agent",
|
||||
payloadSummary: `Hire ${input.name} as ${input.role}`,
|
||||
impactSummary: input.capabilities || `${input.name} will report to ${input.reportsTo ?? token.agentId}.`,
|
||||
payloadJson: JSON.stringify({
|
||||
agentId,
|
||||
requestedByAgentId: token.agentId,
|
||||
requestedConfigurationSnapshot: {
|
||||
connectorId: input.connectorId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
model: input.model ?? null,
|
||||
metadataJson: input.metadataJson,
|
||||
capabilities: input.capabilities,
|
||||
budgetMonthlyUsd: input.budgetMonthlyUsd,
|
||||
reportsTo: input.reportsTo ?? token.agentId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
actor: token.agentId,
|
||||
action: "agent.hire_requested",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
detail: input.name,
|
||||
});
|
||||
this.onDomainChanged();
|
||||
this.json(res, 201, { agentId, approvalId });
|
||||
});
|
||||
|
||||
// POST /api/issues/:issueId/comments — Add comment
|
||||
this.addRoute("POST", "/api/issues/:issueId/comments", (_req, res, params, token, body) => {
|
||||
const input = body as { body?: string } | null;
|
||||
if (!input?.body || typeof input.body !== "string") {
|
||||
this.json(res, 400, { error: "body is required" });
|
||||
return;
|
||||
}
|
||||
if (input.body.length > 4000) {
|
||||
this.json(res, 400, { error: "Comment body must not exceed 4000 characters" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const task = this.db.getTask(params.issueId!);
|
||||
if (task.companyId !== token.companyId) {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
const agent = this.db.getAgent(token.agentId);
|
||||
const comment = this.db.addComment({
|
||||
companyId: task.companyId,
|
||||
taskId: task.id,
|
||||
authorAgentId: token.agentId,
|
||||
authorName: agent.name,
|
||||
body: input.body,
|
||||
});
|
||||
this.onDomainChanged();
|
||||
|
||||
// Step 5b: Wake task assignee (if not the comment author)
|
||||
const wokenForComment = new Set<string>();
|
||||
if (task.assigneeAgentId && task.assigneeAgentId !== token.agentId) {
|
||||
this.onWakeAgent(task.assigneeAgentId, task.companyId, "comment");
|
||||
wokenForComment.add(task.assigneeAgentId);
|
||||
}
|
||||
|
||||
// Wake comment author's manager (org hierarchy — for upward reporting chain)
|
||||
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");
|
||||
}
|
||||
|
||||
this.json(res, 201, comment);
|
||||
} catch {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/issues/:issueId/comments — List comments
|
||||
this.addRoute("GET", "/api/issues/:issueId/comments", (_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;
|
||||
}
|
||||
} catch {
|
||||
this.json(res, 404, { error: "Task not found" });
|
||||
return;
|
||||
}
|
||||
const comments = this.db.listComments(params.issueId!);
|
||||
this.json(res, 200, comments);
|
||||
});
|
||||
|
||||
// POST /api/companies/:companyId/approvals — Request approval
|
||||
this.addRoute("POST", "/api/companies/:companyId/approvals", (_req, res, params, token, body) => {
|
||||
const companyId = params.companyId!;
|
||||
if (companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const parsed = approvalInputSchema.safeParse({
|
||||
companyId,
|
||||
...(typeof body === "object" && body ? body : {}),
|
||||
requestedByAgentId: token.agentId,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
this.json(res, 400, { error: "Invalid approval payload" });
|
||||
return;
|
||||
}
|
||||
const input = parsed.data;
|
||||
if (this.companyRefError(res, companyId, [
|
||||
{ id: input.relatedTaskId, table: "tasks", label: "related task" },
|
||||
{ id: token.agentId, table: "agents", label: "requesting agent" },
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.db.requestApproval({
|
||||
companyId,
|
||||
type: input.type,
|
||||
relatedTaskId: input.relatedTaskId ?? null,
|
||||
requestedByAgentId: token.agentId,
|
||||
payloadSummary: input.payloadSummary,
|
||||
impactSummary: input.impactSummary,
|
||||
});
|
||||
|
||||
this.db.addActivity({
|
||||
companyId,
|
||||
actor: token.agentId,
|
||||
action: "approval.created",
|
||||
entityType: "approval",
|
||||
entityId: id,
|
||||
detail: input.payloadSummary,
|
||||
});
|
||||
|
||||
this.onDomainChanged();
|
||||
this.json(res, 201, { id });
|
||||
});
|
||||
|
||||
// GET /api/companies/:companyId/approvals — List approvals for polling
|
||||
this.addRoute("GET", "/api/companies/:companyId/approvals", (req, res, params, token) => {
|
||||
const companyId = params.companyId!;
|
||||
if (companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`);
|
||||
const parsedQuery = approvalListQuerySchema.safeParse({
|
||||
requestedByAgentId: url.searchParams.get("requestedByAgentId") ?? undefined,
|
||||
relatedTaskId: url.searchParams.get("relatedTaskId") ?? undefined,
|
||||
status: url.searchParams.get("status") ?? undefined,
|
||||
});
|
||||
if (!parsedQuery.success) {
|
||||
this.json(res, 400, { error: "Invalid approval query" });
|
||||
return;
|
||||
}
|
||||
const statuses = parsedQuery.data.status?.split(",").filter(Boolean) ?? null;
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const approvals = snapshot.approvals.filter((approval) => {
|
||||
if (approval.companyId !== companyId) return false;
|
||||
if (parsedQuery.data.requestedByAgentId && approval.requestedByAgentId !== parsedQuery.data.requestedByAgentId) return false;
|
||||
if (parsedQuery.data.relatedTaskId && approval.relatedTaskId !== parsedQuery.data.relatedTaskId) return false;
|
||||
if (statuses && !statuses.includes(approval.state)) return false;
|
||||
return true;
|
||||
});
|
||||
this.json(res, 200, approvals);
|
||||
});
|
||||
|
||||
// GET /api/approvals/:approvalId — Get approval
|
||||
this.addRoute("GET", "/api/approvals/:approvalId", (_req, res, params, token) => {
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const approval = snapshot.approvals.find((a) => a.id === params.approvalId && a.companyId === token.companyId);
|
||||
if (!approval) {
|
||||
this.json(res, 404, { error: "Approval not found" });
|
||||
return;
|
||||
}
|
||||
this.json(res, 200, approval);
|
||||
});
|
||||
|
||||
// GET /api/companies/:companyId/agents — List agents
|
||||
this.addRoute("GET", "/api/companies/:companyId/agents", (_req, res, params, token) => {
|
||||
if (params.companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const agents = snapshot.agents.filter((a) => a.companyId === params.companyId);
|
||||
this.json(res, 200, agents);
|
||||
});
|
||||
|
||||
// GET /api/companies/:companyId/goals — List goals
|
||||
this.addRoute("GET", "/api/companies/:companyId/goals", (_req, res, params, token) => {
|
||||
if (params.companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const goals = snapshot.goals.filter((g) => g.companyId === params.companyId);
|
||||
this.json(res, 200, goals);
|
||||
});
|
||||
|
||||
// GET /api/companies/:companyId/projects — List projects
|
||||
this.addRoute("GET", "/api/companies/:companyId/projects", (_req, res, params, token) => {
|
||||
if (params.companyId !== token.companyId) {
|
||||
this.json(res, 403, { error: "Company mismatch" });
|
||||
return;
|
||||
}
|
||||
const snapshot = this.db.listSnapshot();
|
||||
const projects = snapshot.projects.filter((p) => p.companyId === params.companyId);
|
||||
this.json(res, 200, projects);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import type {
|
||||
AdapterExecutionContext,
|
||||
AdapterExecutionResult,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
ServerAdapterModule,
|
||||
} from "@agentcompany/adapter-utils";
|
||||
import { asNumber, asString, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePathInEnv, runChildProcess } from "@agentcompany/adapter-utils/server-utils";
|
||||
import { agentConfigurationDoc as claudeDoc, models as claudeModels } from "@agentcompany/adapter-claude-local";
|
||||
import { execute as claudeExecute, sessionCodec as claudeSessionCodec, testEnvironment as claudeTestEnvironment } from "@agentcompany/adapter-claude-local/server";
|
||||
import {
|
||||
agentConfigurationDoc as codexDoc,
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
models as codexModels,
|
||||
} from "@agentcompany/adapter-codex-local";
|
||||
import { execute as codexExecute, sessionCodec as codexSessionCodec, testEnvironment as codexTestEnvironment } from "@agentcompany/adapter-codex-local/server";
|
||||
import { agentConfigurationDoc as geminiDoc, DEFAULT_GEMINI_LOCAL_MODEL, models as geminiModels } from "@agentcompany/adapter-gemini-local";
|
||||
import { execute as geminiExecute, sessionCodec as geminiSessionCodec, testEnvironment as geminiTestEnvironment } from "@agentcompany/adapter-gemini-local/server";
|
||||
import { agentConfigurationDoc as opencodeDoc } from "@agentcompany/adapter-opencode-local";
|
||||
import { execute as opencodeExecute, listOpenCodeModels, sessionCodec as opencodeSessionCodec, testEnvironment as opencodeTestEnvironment } from "@agentcompany/adapter-opencode-local/server";
|
||||
import type { CapabilityMatrix, ConnectorId, ConnectorRecord } from "@shared/types";
|
||||
|
||||
function normalizeEnv(input: NodeJS.ProcessEnv): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectorDefinition {
|
||||
id: ConnectorId;
|
||||
label: string;
|
||||
description: string;
|
||||
defaultCommand: string;
|
||||
defaultModel: string | null;
|
||||
notes: string;
|
||||
capabilityMatrix: CapabilityMatrix;
|
||||
configurationDoc: string;
|
||||
module: ServerAdapterModule;
|
||||
listModels?: () => Promise<Array<{ id: string; label: string }>>;
|
||||
}
|
||||
|
||||
const kimiModule: ServerAdapterModule = {
|
||||
type: "kimi_local",
|
||||
async testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult> {
|
||||
const command = asString(ctx.config.command, "kimi");
|
||||
const cwd = asString(ctx.config.cwd, process.cwd());
|
||||
const env = normalizeEnv(ensurePathInEnv({ ...(ctx.config.env as Record<string, string> ?? process.env) }));
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, env);
|
||||
return {
|
||||
adapterType: "kimi_local",
|
||||
status: "warn",
|
||||
checks: [
|
||||
{
|
||||
code: "kimi_command_detected",
|
||||
level: "info",
|
||||
message: `Detected Kimi Code command "${command}".`,
|
||||
},
|
||||
{
|
||||
code: "kimi_runtime_generic_mode",
|
||||
level: "warn",
|
||||
message:
|
||||
"Kimi integration is running in generic CLI mode. Session resume, model discovery, and structured transcript are not guaranteed.",
|
||||
},
|
||||
],
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
adapterType: "kimi_local",
|
||||
status: "fail",
|
||||
checks: [
|
||||
{
|
||||
code: "kimi_command_missing",
|
||||
level: "error",
|
||||
message: error instanceof Error ? error.message : "Kimi command is not available.",
|
||||
hint: "Install Kimi Code and ensure the CLI is available on PATH.",
|
||||
},
|
||||
],
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
},
|
||||
async execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const command = asString(ctx.config.command, "kimi");
|
||||
const cwd = asString(ctx.config.cwd, process.cwd());
|
||||
const model = asString(ctx.config.model, "kimi-for-coding");
|
||||
const timeoutSec = asNumber(ctx.config.timeoutSec, 0);
|
||||
const graceSec = asNumber(ctx.config.graceSec, 20);
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
const prompt = typeof ctx.context.taskTitle === "string" && ctx.context.taskTitle.length > 0
|
||||
? [
|
||||
ctx.context.taskTitle,
|
||||
typeof ctx.context.taskDescription === "string" && ctx.context.taskDescription.length > 0
|
||||
? ctx.context.taskDescription
|
||||
: "",
|
||||
].filter(Boolean).join("\n\n")
|
||||
: [
|
||||
`You are agent ${ctx.agent.name}.`,
|
||||
"Respond with a concise execution summary when complete.",
|
||||
].join("\n\n");
|
||||
|
||||
const env = normalizeEnv(ensurePathInEnv({ ...(ctx.config.env as Record<string, string> ?? process.env) }));
|
||||
await ensureCommandResolvable(command, cwd, env);
|
||||
const result = await runChildProcess(ctx.runId, command, [], {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
stdin: prompt,
|
||||
onLog: ctx.onLog,
|
||||
});
|
||||
|
||||
const summary =
|
||||
result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-8)
|
||||
.join("\n") || null;
|
||||
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
summary,
|
||||
model,
|
||||
billingType: "unknown",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const CONNECTORS: ConnectorDefinition[] = [
|
||||
{
|
||||
id: "codex_local",
|
||||
label: "Codex",
|
||||
description: "Local Codex CLI with session resume, structured event parsing, and model selection.",
|
||||
defaultCommand: "codex",
|
||||
defaultModel: DEFAULT_CODEX_LOCAL_MODEL,
|
||||
notes: "Best fit for high-context coding tasks and shell-heavy automation.",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: codexDoc,
|
||||
module: {
|
||||
type: "codex_local",
|
||||
execute: codexExecute,
|
||||
testEnvironment: codexTestEnvironment,
|
||||
sessionCodec: codexSessionCodec,
|
||||
models: codexModels,
|
||||
},
|
||||
listModels: async () => codexModels,
|
||||
},
|
||||
{
|
||||
id: "claude_local",
|
||||
label: "Claude Code",
|
||||
description: "Local Claude Code CLI with session persistence and structured result parsing.",
|
||||
defaultCommand: "claude",
|
||||
defaultModel: claudeModels[0]?.id ?? null,
|
||||
notes: "Useful for long-running coding sessions and agent-style loops.",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: true,
|
||||
},
|
||||
configurationDoc: claudeDoc,
|
||||
module: {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
models: claudeModels,
|
||||
},
|
||||
listModels: async () => claudeModels,
|
||||
},
|
||||
{
|
||||
id: "gemini_local",
|
||||
label: "Gemini CLI",
|
||||
description: "Local Gemini CLI with resume support and JSON event parsing.",
|
||||
defaultCommand: "gemini",
|
||||
defaultModel: DEFAULT_GEMINI_LOCAL_MODEL,
|
||||
notes: "Good for multi-provider local workflows when Google auth is already in place.",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: true,
|
||||
costAttribution: true,
|
||||
},
|
||||
configurationDoc: geminiDoc,
|
||||
module: {
|
||||
type: "gemini_local",
|
||||
execute: geminiExecute,
|
||||
testEnvironment: geminiTestEnvironment,
|
||||
sessionCodec: geminiSessionCodec,
|
||||
models: geminiModels,
|
||||
},
|
||||
listModels: async () => geminiModels,
|
||||
},
|
||||
{
|
||||
id: "opencode_local",
|
||||
label: "OpenCode",
|
||||
description: "Local OpenCode runtime with provider/model routing and session restore.",
|
||||
defaultCommand: "opencode",
|
||||
defaultModel: null,
|
||||
notes: "Best when model routing belongs to the runtime rather than the app.",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: true,
|
||||
structuredTranscript: true,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: opencodeDoc,
|
||||
module: {
|
||||
type: "opencode_local",
|
||||
execute: opencodeExecute,
|
||||
testEnvironment: opencodeTestEnvironment,
|
||||
sessionCodec: opencodeSessionCodec,
|
||||
models: [],
|
||||
listModels: listOpenCodeModels,
|
||||
},
|
||||
listModels: listOpenCodeModels,
|
||||
},
|
||||
{
|
||||
id: "kimi_local",
|
||||
label: "Kimi Code",
|
||||
description: "Official Kimi Code CLI surfaced through a generic execution contract with explicit capability downgrade.",
|
||||
defaultCommand: "kimi",
|
||||
defaultModel: "kimi-for-coding",
|
||||
notes: "MVP support is explicit generic mode until richer runtime contract evidence is implemented.",
|
||||
capabilityMatrix: {
|
||||
sessionResume: false,
|
||||
gracefulCancel: false,
|
||||
modelDiscovery: false,
|
||||
structuredTranscript: false,
|
||||
costAttribution: false,
|
||||
},
|
||||
configurationDoc: [
|
||||
"# kimi_local agent configuration",
|
||||
"",
|
||||
"Core fields:",
|
||||
"- command (string, optional): defaults to \"kimi\"",
|
||||
"- cwd (string, optional): working directory for the Kimi CLI process",
|
||||
"- model (string, optional): default kimi-for-coding",
|
||||
"- timeoutSec (number, optional)",
|
||||
"- graceSec (number, optional)",
|
||||
"",
|
||||
"Notes:",
|
||||
"- This connector currently runs Kimi in generic CLI mode.",
|
||||
"- Session resume, model discovery, structured transcript, and cost attribution are declared unsupported.",
|
||||
"- The UI must surface these limitations explicitly.",
|
||||
].join("\n"),
|
||||
module: kimiModule,
|
||||
listModels: async () => [{ id: "kimi-for-coding", label: "kimi-for-coding" }],
|
||||
},
|
||||
];
|
||||
|
||||
export function listConnectorDefinitions(): ConnectorDefinition[] {
|
||||
return CONNECTORS;
|
||||
}
|
||||
|
||||
export function getConnectorDefinition(id: ConnectorId): ConnectorDefinition {
|
||||
const definition = CONNECTORS.find((entry) => entry.id === id);
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown connector: ${id}`);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function parseEnvBindingText(text: string): Record<string, string> {
|
||||
const bindings: Record<string, string> = {};
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const [envKey, ...rest] = line.split("=");
|
||||
const key = envKey?.trim();
|
||||
const value = rest.join("=").trim();
|
||||
if (!key || !value) continue;
|
||||
bindings[key] = value;
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
export function buildDefaultConnectorRows(existing: Array<Partial<ConnectorRecord>> = []): ConnectorRecord[] {
|
||||
const byId = new Map(existing.map((record) => [record.id, record]));
|
||||
return CONNECTORS.map((definition) => {
|
||||
const existingRecord = byId.get(definition.id);
|
||||
return {
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
status: existingRecord?.status ?? "not_installed",
|
||||
command: definition.defaultCommand,
|
||||
version: existingRecord?.version ?? null,
|
||||
authState: existingRecord?.authState ?? "unknown",
|
||||
lastCheckedAt: existingRecord?.lastCheckedAt ?? null,
|
||||
lastError: existingRecord?.lastError ?? null,
|
||||
model: existingRecord?.model ?? definition.defaultModel,
|
||||
envBindingText: existingRecord?.envBindingText ?? "",
|
||||
notes: existingRecord?.notes ?? definition.notes,
|
||||
configurationDoc: definition.configurationDoc,
|
||||
capabilityMatrix: definition.capabilityMatrix,
|
||||
};
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+1935
File diff suppressed because it is too large.
Load diff
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user