Compare commits
No commits in common. "c2028f45c53ed8017bdbbbcad2c2fbd1aa4c607b" and "25378f5e353edc90ebbbce326212e9c2f286ea5c" have entirely different histories.
c2028f45c5
...
25378f5e35
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,5 +3,4 @@ gadget-drone.*.log
|
||||
logfetch
|
||||
|
||||
.gadget
|
||||
gadget/
|
||||
node_modules
|
||||
|
||||
@ -114,9 +114,3 @@ If code is needed by both consumer packages, it belongs in the appropriate share
|
||||
- MongoDB on `localhost:27017`
|
||||
- Redis on `localhost:6379`
|
||||
- SSL certificates in `gadget-code/ssl/` directory (for dev servers)
|
||||
|
||||
## .gadget Directory
|
||||
|
||||
The `.gadget/` directory at the project root contains plans, TODOs, and knowledge
|
||||
artifacts managed by Gadget Code. Do not modify these files unless you understand
|
||||
their purpose and format.
|
||||
|
||||
289
CHANGE.md
289
CHANGE.md
@ -1,289 +0,0 @@
|
||||
# CHANGELOG
|
||||
|
||||
This document tracks all significant changes to Gadget Code, organized by date.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-17 (Sunday)
|
||||
|
||||
**Release Hardening, Subagent Improvements, and Workspace Intelligence**
|
||||
|
||||
The day focused on preparing the v1.0.1 release with a hardened release script, fixing subagent context management, adding prompt draft persistence, and implementing intelligent workspace startup that detects the workspace directory by walking up the file hierarchy.
|
||||
|
||||
### Changes:
|
||||
- **Release**: Added hardened release script with error handling and pre-flight checks.
|
||||
- **Subagent**: Improved subagent context management for more reliable agent operations.
|
||||
- **Chat Session**: Implemented prompt draft save/restore so in-progress prompts survive view changes.
|
||||
- **Workspace**: Added intelligent startup — drone detects workspace by walking up directory hierarchy.
|
||||
- **Agent Tools**: Updated plan tool with improved prompt language and behavior.
|
||||
- **Prompt Editor**: Expanded prompt editor capabilities.
|
||||
- **gadget-tasks**: Course correction — converted to headless IDE client architecture (no direct DB access).
|
||||
- **Documentation**: Added gadget-tasks architecture documentation; updated README and agent-toolbox docs.
|
||||
- **Build**: Fixed installation and global link issues.
|
||||
- **Tests**: Updated code-session tests to match current API expectations.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-16 (Saturday)
|
||||
|
||||
**AI Toolbox Extraction and Project Manager Upgrades**
|
||||
|
||||
Completed the refactor extracting the AI toolbox from `@gadget/ai` into `@gadget/ai-toolbox`, and implemented Project Manager upgrades across Phases 1–4. Cleaned up incorrect dependencies.
|
||||
|
||||
### Changes:
|
||||
- **AI Toolbox**: Completed refactor and extraction from `@gadget/ai` into `@gadget/ai-toolbox`.
|
||||
- **Project Manager**: Implemented upgrades across Phases 1–4.
|
||||
- **Dependencies**: Removed incorrect `simple-git` package; fixed type references.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-15 (Thursday)
|
||||
|
||||
**Pull Project, Tool Call Observability, and Context Window Configuration**
|
||||
|
||||
Introduced the Pull Project feature for cloning remote repositories, added tool call observability to the chat session view, and added `numCtx` (Context Window) configuration to the SESSION panel. Also refactored ChatSessionView and added the Gab AI affiliate link.
|
||||
|
||||
### Changes:
|
||||
- **Pull Project**: Initial implementation for cloning remote repositories into the workspace.
|
||||
- **Observability**: Added tool call observability to the chat session view.
|
||||
- **File Operations**: Added new file creation capability.
|
||||
- **SESSION Panel**: Added `numCtx` (Context Window) setting; updated model config pipeline with `numPredict`, `numCtx`, and `maxCompletionTokens`.
|
||||
- **ChatSessionView**: Refactored for improved structure; added scripts directory.
|
||||
- **Provider**: Added Gab AI affiliate link; changed Ollama abort procedure.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-14 (Wednesday)
|
||||
|
||||
**SubProcess Observability Implementation**
|
||||
|
||||
Implemented comprehensive observability for SubProcess operations within the DroneManager and DroneInspector components, including status tracking, event emission, and UI display of subprocess execution details.
|
||||
|
||||
### Changes:
|
||||
- **SubProcess**: Implemented Phase 1 (SubProcess service and agent tool) and Phase 2 (observability in DroneManager and DroneInspector).
|
||||
- **UI**: Updated to display subprocess execution details.
|
||||
- **Fixes**: Fixed mode change log message formatting.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-13 (Tuesday)
|
||||
|
||||
**CodeMirror Editor Fix and User Mode Documentation**
|
||||
|
||||
Fixed a critical CodeMirror editor height constraint in flex layouts, and added documentation for User Mode continuation prompts and MVP completion summary.
|
||||
|
||||
### Changes:
|
||||
- **Editor**: Fixed CodeMirror editor height constraint in flex layout.
|
||||
- **Documentation**: Added continuation prompt and remaining steps for User Mode; added session summary for MVP completion; formatting cleanup in AGENTS.md.
|
||||
- **Infrastructure**: Prep work for landing page project.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-12 (Monday)
|
||||
|
||||
**FILES Panel, Editor Integration, and Abort Controller**
|
||||
|
||||
Major milestone: implemented the FILES panel foundation with lazy-loading file tree, replaced react-ace with @uiw/react-codemirror (React 19 compat), added an abort controller for work order processing, and fixed numerous file tree and layout issues.
|
||||
|
||||
### Changes:
|
||||
- **FILES Panel**: Implemented foundation with lazy-loading file tree; fixed scrolling, duplication, corruption, and expand/collapse rendering issues.
|
||||
- **Editor**: Replaced react-ace with @uiw/react-codemirror for React 19 compatibility; fixed ACE editor crash (Vite `?url` pattern, Error Boundary).
|
||||
- **Layout**: Fixed flexbox scrolling for sidebar; added `shrink-0` to SESSION and PROJECT panels.
|
||||
- **Abort Controller**: Implemented abort controller for work order processing; added Aborted status.
|
||||
- **Navigation**: Added navigation to project in Project Manager using slug.
|
||||
- **Socket**: Defined missing socket event types; enforced typed events in frontend build.
|
||||
- **AI**: Fixed OpenAI API tool call processing correctness.
|
||||
- **Agent**: Moved plan prompt language from common block; added plan for project-specific agent instructions.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-11 (Sunday)
|
||||
|
||||
**Subagent Processing, Authentication, and Deployment Documentation**
|
||||
|
||||
Focused on subagent processing improvements and authentication fixes, including subagent response handling, session data management, persona field in sign-in responses, and JWT/heartbeat fixes. Added deployment documentation with installation guides, config examples, and systemd service files.
|
||||
|
||||
### Changes:
|
||||
- **Subagent**: Fixed subagent response handling and session data; crushed whitespace in subagent markdown output.
|
||||
- **Auth**: Added persona field to sign-in response; fixed JWT expiration bugs and drone heartbeat timeouts.
|
||||
- **ChatSession**: Added reconnect logic; fixed crash.
|
||||
- **Deployment**: Added installation guides, config examples, and systemd service files.
|
||||
- **Model Config**: Added `numPredict`, `numCtx`, `maxCompletionTokens` to model configuration pipeline.
|
||||
- **Style**: CSS and style fixes for subagent display; updated build prompt.
|
||||
- **Architecture**: Integrated docs/archive for reference; subagents system (written by an agent, for an agent).
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-10 (Saturday)
|
||||
|
||||
**Workspace and Project Philosophy Refinement**
|
||||
|
||||
Refined the conceptual model separating Workspaces from Projects, improved the chat interface, and fixed credential provider and API key distribution issues.
|
||||
|
||||
### Changes:
|
||||
- **Architecture**: Applied philosophy shifts for Workspaces and Projects; updated conceptual model.
|
||||
- **Agent Toolbox**: Refactored and updated agent toolbox.
|
||||
- **Fixes**: Fixed credential provider; ensured apiKey is presented to required consumers.
|
||||
- **Chat**: Chat interface fixes and cleanup.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-09 (Friday)
|
||||
|
||||
**Agentic Workflow Loop Rebuild**
|
||||
|
||||
Rebuilt the Agentic Workflow Loop from the ground up for improved reliability and performance, with enhanced streaming response handling, chat session auto-naming, and drone-to-IDE log transport.
|
||||
|
||||
### Changes:
|
||||
- **AWL**: Re-built Agentic Workflow Loop with improved reliability and checkpointing.
|
||||
- **Streaming**: Enhanced streaming response handling and correctness.
|
||||
- **Chat Session**: Implemented auto-naming with IDE update.
|
||||
- **Logging**: Added GadgetLogTransportSocket for drone-to-IDE log streaming.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-08 (Thursday)
|
||||
|
||||
**Chat Session Heartbeat, User Settings, and Logging Unification**
|
||||
|
||||
Implemented chat session heartbeat and session unlock, user settings management, configurable reasoning effort, and unified logging into `@gadget/api` as GadgetLog. Redesigned sign-in form and provider/model selection.
|
||||
|
||||
### Changes:
|
||||
- **Session**: Implemented chat session heartbeat and session unlock.
|
||||
- **User Settings**: Added user settings management; made reasoning effort configurable.
|
||||
- **Auth**: Redesigned sign-in form and presentation; removed sign-up concept; integrated persona field in User.
|
||||
- **UI**: Reworked provider/model selection with save/cancel pattern; added editable session name with cog icon; added navigation to return to Project Manager.
|
||||
- **Logging**: Unified logging into `@gadget/api` as GadgetLog; pre-task cleanup to reduce log spam; fixed logging when file logging is disabled.
|
||||
- **Streaming**: Fixed streaming response handling for Ollama.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-07 (Wednesday)
|
||||
|
||||
**Streaming Responses Implementation**
|
||||
|
||||
Implemented streaming responses for AI model interactions, including proper handling of streaming data from Ollama and other providers with real-time token delivery and response completion tracking.
|
||||
|
||||
### Changes:
|
||||
- **Streaming**: Implemented streaming responses for Ollama with real-time token delivery and completion tracking.
|
||||
- **Documentation**: Created documentation for streaming responses.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-06 (Tuesday)
|
||||
|
||||
**Agent Tool and Toolbox Architecture**
|
||||
|
||||
Significant refactoring of the agent tool and toolbox architecture, establishing the foundational pattern for tool registration and management.
|
||||
|
||||
### Changes:
|
||||
- **Architecture**: Refactored agent tool and toolbox; established tool registration and management patterns.
|
||||
- **AI Environment**: Agent, tools, toolbox, tool loop, and AI environment foundation.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-05 (Monday)
|
||||
|
||||
**Basic Chat System Milestone**
|
||||
|
||||
Achieved a working basic chat system — the first end-to-end milestone — with provider, model, and mode selection, ChatTurn processing, and agent progress tracking.
|
||||
|
||||
### Changes:
|
||||
- **Chat**: Implemented basic working chat system (checkpoint).
|
||||
- **Selection**: Added provider, model, and mode selections.
|
||||
- **Processing**: Enhanced AgentService.process method; implemented ChatTurn processing and component.
|
||||
- **Infrastructure**: Renamed platform.apiKey to platform.gadgetKey; reduced log output.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-03 (Saturday)
|
||||
|
||||
**Workspace Mode Management and Status Display**
|
||||
|
||||
Fixed status message display in the agent interface and implemented workspace mode management with drone status message socket events.
|
||||
|
||||
### Changes:
|
||||
- **Status**: Fixed status message display in agent interface.
|
||||
- **Workspace**: Implemented workspace mode management; added drone status message socket events.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-02 (Friday)
|
||||
|
||||
**Workspace Mode Switching with Validation**
|
||||
|
||||
Implemented workspace mode switching with validation, ensuring workspace mode changes are validated before being applied, along with session lock and workspace processing refinements.
|
||||
|
||||
### Changes:
|
||||
- **Workspace**: Implemented workspace mode switching with validation.
|
||||
- **Session**: Added session lock and workspace processing refinements.
|
||||
- **Socket**: Socket message and drone workspace cleanup; drone doc updates.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-01 (Thursday)
|
||||
|
||||
**The Grid Home View and GadgetId Migration**
|
||||
|
||||
Implemented the "Welcome to The Grid" home view, fixed JWT processing, refactored session lifecycle management, and completed the transition from Types.ObjectId to GadgetId across the codebase.
|
||||
|
||||
### Changes:
|
||||
- **Home View**: Implemented "Welcome to The Grid" home view; return to Home on sign out.
|
||||
- **Auth**: Fixed JWT processing.
|
||||
- **Session**: Refactored session startup, shutdown, and import; fixed import errors.
|
||||
- **GadgetId**: Completed transition from Types.ObjectId to GadgetId (string alias using nanoid); correctly generate nanoid IDs in default clauses.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-30 (Wednesday)
|
||||
|
||||
**Socket Protocol and Drone Manager**
|
||||
|
||||
Implemented socket protocol completeness with typed events, created the DroneManager, and finalized Project Manager correctness.
|
||||
|
||||
### Changes:
|
||||
- **Socket Protocol**: Implemented socket protocol completeness; defined missing event types; enforced typed events in frontend build.
|
||||
- **Drone Manager**: Created DroneManager (WIP).
|
||||
- **Config**: Added setting for max Socket.IO HTTP buffer size.
|
||||
- **Project Manager**: Wrap-up on Project Manager correctness.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-29 (Tuesday)
|
||||
|
||||
**Drone-to-IDE Event Routing and Workspace Persistence**
|
||||
|
||||
Implemented drone-to-IDE event routing, added event emissions to the Agentic Workflow Loop, and implemented workspace persistence and crash recovery. Added workspace mode to the gadget-drone process.
|
||||
|
||||
### Changes:
|
||||
- **Event Routing**: Implemented Drone→IDE event routing (Phase 3); added event emissions to AWL (Phase 4).
|
||||
- **Workspace**: Added workspace mode to gadget-drone process; implemented workspace persistence and crash recovery (Phase 5).
|
||||
- **Protocol**: Implemented prompt submission; fixed type conflicts; added socket protocol documentation.
|
||||
- **Config**: Added max Socket.IO HTTP buffer size setting; standardized interfaces.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-28 (Monday)
|
||||
|
||||
**Dark Industrial Theme, JWT Auth, and Project Manager**
|
||||
|
||||
Significant visual and architectural milestone: implemented the dark industrial theme, Project Manager, and JWT authentication, replacing sign-up with streamlined sign-in.
|
||||
|
||||
### Changes:
|
||||
- **Theme**: Implemented dark industrial theme with correct border colors and enforcement.
|
||||
- **Auth**: Implemented JWT authentication.
|
||||
- **Project Manager**: Added project service.
|
||||
- **Models**: Refactored gadget-code model interfaces to `@gadget/api`.
|
||||
- **Infrastructure**: Added workspaceDir to DroneRegistration; E2E tests for theme.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-27 (Sunday)
|
||||
|
||||
**Initial Commit**
|
||||
|
||||
The project was initialized, establishing the foundation for all subsequent development.
|
||||
|
||||
### Changes:
|
||||
- Initial project commit.
|
||||
- Established basic project structure.
|
||||
63
README.md
63
README.md
@ -21,15 +21,12 @@ pnpm dev
|
||||
|
||||
## Projects
|
||||
|
||||
| Package | Role |
|
||||
| ------------------ | ------------------------------------------------------------------------ |
|
||||
| `gadget-code` | Web service — agentic IDE, browser UI, API server |
|
||||
| `gadget-drone` | Worker process — runs the agentic workflow loop in workspace directories |
|
||||
| `gadget-tasks` | Scheduled task worker — headless IDE client for cron-driven tasks |
|
||||
| `@gadget/ai` | Shared AI API abstraction — Ollama and OpenAI |
|
||||
| `@gadget/ai-toolbox` | Shared AI tool implementations — search, file, plan, subagent tools |
|
||||
| `@gadget/api` | Shared TypeScript interfaces — common types across all packages |
|
||||
| `@gadget/config` | Shared YAML config loader — per-package configuration |
|
||||
| Package | Role |
|
||||
| -------------- | ------------------------------------------------------------------------ |
|
||||
| `gadget-code` | Web service — agentic IDE, browser UI, API server |
|
||||
| `gadget-drone` | Worker process — runs the agentic workflow loop in workspace directories |
|
||||
| `@gadget/ai` | Shared AI API abstraction — Ollama and OpenAI |
|
||||
| `@gadget/api` | Shared TypeScript interfaces — common types across all packages |
|
||||
|
||||
## Architecture
|
||||
|
||||
@ -53,30 +50,6 @@ pnpm dev
|
||||
6. **Streaming response** flows back: thinking → response → tool calls
|
||||
7. **Results persist** in MongoDB as ChatTurn records
|
||||
|
||||
### Scheduled Tasks (gadget-tasks)
|
||||
|
||||
gadget-tasks is a **headless IDE client** that automates the browser IDE flow on a cron schedule. It does not duplicate AI, database, or workspace code — it drives the existing gadget-code platform via REST API and Socket.IO, the same protocol the browser IDE uses.
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ gadget-tasks │────▶│ gadget-code │────▶│ gadget-drone │
|
||||
│ (Scheduler) │◀────│ (Express 5) │◀────│ (Worker) │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
│ │ │
|
||||
│ REST + Socket.IO │ MongoDB │ Files
|
||||
│ JWT Auth │ Redis │ Git
|
||||
```
|
||||
|
||||
**Per-task flow:**
|
||||
1. CronJob fires → create ChatSession via REST API
|
||||
2. Lock drone via Socket.IO `requestSessionLock`
|
||||
3. Set workspace mode to Agent via `requestWorkspaceMode`
|
||||
4. Submit task prompt via `submitPrompt` → drone processes work order
|
||||
5. Receive `workOrderComplete` → release session lock
|
||||
6. Update `task.lastRun` via `PATCH /projects/:id/tasks/:taskId/lastRun`
|
||||
|
||||
See [gadget-tasks documentation](./docs/gadget-tasks.md) for setup and usage.
|
||||
|
||||
## AI Provider Setup
|
||||
|
||||
Before using AI features, add a provider via CLI:
|
||||
@ -147,21 +120,6 @@ pnpm dev
|
||||
# Drone worker (in a workspace directory)
|
||||
cd ~/my-gadget-workspace
|
||||
pnpm --filter gadget-drone dev
|
||||
|
||||
# Task scheduler (requires running gadget-code + drone)
|
||||
cd gadget-tasks
|
||||
pnpm dev -- --user=admin@example.com --password=secret
|
||||
```
|
||||
|
||||
### Global Install (CLI)
|
||||
|
||||
```bash
|
||||
# Link gadget-drone and gadget-tasks globally for CLI use
|
||||
pnpm link:global
|
||||
|
||||
# Now available as system commands
|
||||
gadget-drone
|
||||
gadget-tasks --user=admin@example.com --password=secret
|
||||
```
|
||||
|
||||
### Testing
|
||||
@ -225,16 +183,13 @@ See [`packages/ai/README.md`](./packages/ai/README.md) for the full API referenc
|
||||
- [Workspace Management](./docs/gadget-workspace.md)
|
||||
- [Drone Documentation](./gadget-drone/docs/gadget-drone.md)
|
||||
- [UI Design Guide](./gadget-code/docs/ui-design-guide.md)
|
||||
- [gadget-tasks Documentation](./docs/gadget-tasks.md)
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
gadget/
|
||||
├── packages/ai/ # @gadget/ai — AI API abstraction
|
||||
├── packages/ai-toolbox/ # @gadget/ai-toolbox — Shared tool implementations
|
||||
├── packages/api/ # @gadget/api — Shared interfaces
|
||||
├── packages/config/ # @gadget/config — YAML config loader
|
||||
├── gadget-code/ # Web service + browser IDE
|
||||
│ ├── src/ # Backend (Express, Socket.IO, Mongoose)
|
||||
│ ├── frontend/ # Frontend (React, Vite, Tailwind)
|
||||
@ -242,8 +197,6 @@ gadget/
|
||||
├── gadget-drone/ # Worker process
|
||||
│ ├── src/ # Drone implementation
|
||||
│ └── docs/ # Drone documentation
|
||||
├── gadget-tasks/ # Scheduled task worker
|
||||
│ └── src/ # Headless IDE client + cron scheduler
|
||||
└── docs/ # Architecture & protocol docs
|
||||
```
|
||||
|
||||
@ -253,5 +206,5 @@ Apache 2.0 — See [LICENSE](./LICENSE) for details.
|
||||
|
||||
---
|
||||
|
||||
**Status:** v1.0.1 — First release. Self-hosted agentic engineering platform with local/self-hosted AI model support, complete Chat Session UI, subagent processing, and workspace management.
|
||||
**Last Updated:** May 17, 2026
|
||||
**Status:** Production-ready foundation with complete Chat Session UI
|
||||
**Last Updated:** April 29, 2026
|
||||
|
||||
120
RELEASE.md
120
RELEASE.md
@ -1,120 +0,0 @@
|
||||
GADGET CODE v1.0.1
|
||||
NAME: Liberation
|
||||
HASH: ff46c35d38e29be64d9eca0ccc18e8a7f5c3d8ab
|
||||
TAG: v1.0.1
|
||||
|
||||
SUMMARY:
|
||||
|
||||
Today marks the Liberation of Gadget Code — the first public release of a self-hosted agentic engineering platform that puts you in control of your code, your models, and your infrastructure.
|
||||
|
||||
Over 200 commits spanning 21 days of intensive development, Gadget Code has grown from an initial commit into a complete platform for autonomous software engineering. And every line of it is Apache-2.0 licensed, fully open source, and designed to run on *your* terms.
|
||||
|
||||
## What Is Gadget Code?
|
||||
|
||||
Gadget Code is an **Agentic Engineering Platform (AEP)** — a browser-based IDE that drives autonomous AI agents to perform real software engineering work on your behalf. Unlike cloud-locked alternatives, Gadget Code runs entirely in your environment: your servers, your data, your rules.
|
||||
|
||||
The architecture is straightforward and powerful:
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Browser IDE │────▶│ gadget-code │────▶│ gadget-drone │
|
||||
│ (React 19) │◀────│ (Express 5) │◀────│ (Worker) │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
│ │ │
|
||||
│ Socket.IO │ MongoDB │ Files
|
||||
│ JWT Auth │ Redis │ Git
|
||||
```
|
||||
|
||||
A user creates a project in the browser IDE, selects a drone instance, enters a prompt, and the drone executes the Agentic Workflow Loop — thinking, responding, calling tools, spawning subagents — all streamed back in real time.
|
||||
|
||||
## Your Models, Your Way
|
||||
|
||||
This is what Liberation means. Gadget Code ships with first-class support for **Ollama** and any **OpenAI-compatible API**. Run local models on your own GPU. Self-host an inference endpoint behind your firewall. Use a commercial provider when it suits you. Mix and match per session. The `@gadget/ai` package provides a unified abstraction — no consumer code ever imports an SDK directly. Swap providers without changing a line of application logic.
|
||||
|
||||
The model configuration pipeline supports `numCtx` (context window), `numPredict`, and `maxCompletionTokens` settings, giving you fine-grained control over how your models behave.
|
||||
|
||||
## Complete Agentic Engineering Platform
|
||||
|
||||
Gadget Code isn't a chatbot wrapper. It's a full-stack engineering environment:
|
||||
|
||||
- **Project Manager** — Create projects, select available drone instances, manage chat session history.
|
||||
- **Chat Session View** — Real-time streaming responses with collapsible thinking content, tool call summaries, session statistics, and per-session model/mode selection.
|
||||
- **Agentic Workflow Loop** — The drone executes a multi-turn reasoning loop: think, respond, call tools, observe results, and iterate. Work orders are abortable, and the loop supports checkpointing for crash recovery.
|
||||
- **Subagent Processing** — Agents can spawn specialized subagents for decomposition tasks. Subagent context is managed independently and streamed back to the IDE in real time.
|
||||
- **FILE Panel** — Lazy-loading file tree with full workspace navigation and CodeMirror editor integration (React 19 compatible).
|
||||
- **Workspace Management** — Each drone manages a workspace directory with crash recovery via the `.gadget/` directory and intelligent startup that detects the workspace by walking up the file hierarchy.
|
||||
- **Scheduled Tasks (gadget-tasks)** — A headless IDE client that automates the browser flow on a cron schedule. It drives the existing gadget-code platform via the same REST API and Socket.IO protocol the browser uses — no duplicated logic.
|
||||
- **Pull Project** — Clone remote repositories directly into your workspace.
|
||||
- **Abort Controller** — Cancel in-progress work orders with proper state cleanup.
|
||||
|
||||
## Configuration and Dependencies
|
||||
|
||||
Gadget Code requires:
|
||||
|
||||
- **Node.js 22+** and **pnpm 10+**
|
||||
- **MongoDB** on `localhost:27017`
|
||||
- **Redis** on `localhost:6379`
|
||||
- **SSL certificates** in the `ssl/` directory for dev servers
|
||||
- An **Ollama** instance or **OpenAI-compatible API** endpoint for AI features
|
||||
|
||||
The monorepo is organized as follows:
|
||||
|
||||
| Package | Role |
|
||||
| -------------------- | --------------------------------------------------------------- |
|
||||
| `gadget-code` | Web service — agentic IDE, browser UI, API server |
|
||||
| `gadget-drone` | Worker process — runs the agentic workflow loop |
|
||||
| `gadget-tasks` | Scheduled task worker — headless IDE client for cron-driven tasks |
|
||||
| `@gadget/ai` | Shared AI API abstraction — Ollama and OpenAI |
|
||||
| `@gadget/ai-toolbox` | Shared AI tool implementations — search, file, plan, subagent |
|
||||
| `@gadget/api` | Shared TypeScript interfaces — common types across all packages |
|
||||
| `@gadget/config` | Shared YAML config loader — per-package configuration |
|
||||
|
||||
AI providers are managed via CLI:
|
||||
|
||||
```bash
|
||||
# Add Ollama (no API key needed)
|
||||
pnpm cli provider add "Local Ollama" ollama http://localhost:11434
|
||||
|
||||
# Add OpenAI-compatible provider
|
||||
pnpm cli provider add "OpenAI" openai https://api.openai.com $OPENAI_API_KEY
|
||||
|
||||
# Discover available models
|
||||
pnpm cli provider probe <provider-id>
|
||||
```
|
||||
|
||||
## The Journey to v1.0.1
|
||||
|
||||
This release represents 21 days of development from April 27 through May 17, 2026:
|
||||
|
||||
- **Days 1–2** (Apr 27–28): Project initialization, dark industrial theme, JWT authentication, Project Manager, drone-to-IDE event routing, workspace persistence and crash recovery.
|
||||
- **Days 3–4** (Apr 29–30): Socket protocol completeness, DroneManager, GadgetId migration from ObjectId to nanoid-based string IDs, "Welcome to The Grid" home view.
|
||||
- **Days 5–6** (May 1–3): Workspace mode switching with validation, session locks, basic chat system milestone — the first end-to-end working demo.
|
||||
- **Days 7–8** (May 5–6): Agent tool and toolbox architecture, tool registration patterns, AI environment foundation.
|
||||
- **Day 9** (May 7): Streaming responses implementation — real-time token delivery from Ollama.
|
||||
- **Day 10** (May 8): Chat session heartbeat, user settings, logging unification into `@gadget/api` as GadgetLog, redesigned sign-in form.
|
||||
- **Day 9 continued** (May 9): Agentic Workflow Loop rebuild, chat session auto-naming, drone-to-IDE log transport.
|
||||
- **Day 11** (May 10): Workspace/project philosophy refinement, agent toolbox refactor, credential provider fixes.
|
||||
- **Day 12** (May 11): Subagent processing, authentication fixes, deployment documentation with systemd service files.
|
||||
- **Day 13** (May 12): FILES panel with lazy-loading file tree, CodeMirror editor integration (replacing react-ace for React 19 compat), abort controller.
|
||||
- **Day 14** (May 13): Editor height fixes, User Mode documentation.
|
||||
- **Day 15** (May 14): SubProcess observability — tracking spawned processes in the DroneManager and DroneInspector.
|
||||
- **Day 16** (May 15): Pull Project feature, tool call observability, context window configuration, Gab AI affiliate link.
|
||||
- **Days 17–18** (May 16–17): AI toolbox extraction into `@gadget/ai-toolbox`, Project Manager upgrades, gadget-tasks headless client architecture, release hardening.
|
||||
|
||||
## Project Direction
|
||||
|
||||
Gadget Code is built on a simple conviction: **your code, your models, your infrastructure**. The cloud AI ecosystem has moved toward vendor lock-in at every layer — proprietary models, closed platforms, data harvesting. Liberation is the alternative. Every component of Gadget Code is self-hosted. Every AI call goes through your providers. Every file stays on your disk. Every model runs on your hardware if you want it to.
|
||||
|
||||
This is also the first release where the release document itself is authored by Gadget — the AI agent — as part of the automated release workflow. The agent that writes code for you also writes the release notes. We think that's fitting.
|
||||
|
||||
## Get Started
|
||||
|
||||
Get the latest release at **https://g4dge7.com/** — the official home of Gadget Code.
|
||||
|
||||
Apache-2.0 licensed. Fully open source. No vendor lock-in. No data harvesting. No cloud dependency. Just you, your models, and your code.
|
||||
|
||||
That's Liberation.
|
||||
|
||||
---
|
||||
|
||||
*This RELEASE.md was authored by Gadget — the AI agent itself — as part of the automated release workflow. Gadget Code is licensed under the Apache-2.0 license. Both of these facts — an AI agent that can produce its own release notes, and a permissive open-source license that lets you build anything — are great reasons to switch to Gadget Code today. Get the latest release at https://g4dge7.com/.*
|
||||
@ -16,33 +16,27 @@ or is aborted, the abort signal mechanism is cleaned up end-to-end.
|
||||
|
||||
```
|
||||
[Frontend] --abortWorkOrder(cb)--> [CodeSession] --abortWorkOrder(cb)--> [Drone]
|
||||
|
|
||||
AgentService
|
||||
.abortCurrentWorkOrder()
|
||||
|
|
||||
abortController.abort()
|
||||
|
|
||||
AiService.abort()
|
||||
(calls AI provider's
|
||||
abort() which for
|
||||
Ollama terminates the
|
||||
HTTP request immediately)
|
||||
|
|
||||
cb(true, "Abort signaled")
|
||||
|
|
||||
|
|
||||
AgentService
|
||||
.abortCurrentWorkOrder()
|
||||
|
|
||||
abortController.abort()
|
||||
|
|
||||
cb(true, "Abort signaled")
|
||||
|
|
||||
[Frontend] <------- callback received, show "Aborting..." -----------------------+
|
||||
|
|
||||
(AbortError thrown
|
||||
in AI provider)
|
||||
|
|
||||
AgentService catches
|
||||
AbortError, emits
|
||||
workOrderComplete(turnId, true, "aborted")
|
||||
|
|
||||
|
|
||||
(AbortError thrown
|
||||
in AI provider)
|
||||
|
|
||||
AgentService catches
|
||||
AbortError, emits
|
||||
workOrderComplete(turnId, true, "aborted")
|
||||
|
|
||||
[DroneSession] --workOrderComplete(turnId, true, "aborted")--> [CodeSession]
|
||||
| |
|
||||
turn.status = turn.status = "aborted"
|
||||
ChatTurnStatus.Aborted displayed subtly
|
||||
| |
|
||||
turn.status = turn.status = "aborted"
|
||||
ChatTurnStatus.Aborted displayed subtly
|
||||
```
|
||||
|
||||
**Key design decision**: `workOrderComplete` is used with `success=true` and
|
||||
@ -80,10 +74,6 @@ No `SocketEvents` changes needed — the drone listens on its own socket.
|
||||
- `IAiChatOptions`
|
||||
- `IAiGenerateOptions`
|
||||
|
||||
Also added a default (no-op) `abort()` method on the `AiApi` abstract class.
|
||||
Provider-specific implementations override this to forcefully terminate
|
||||
in-progress HTTP requests.
|
||||
|
||||
**`packages/ai/src/ollama.ts`** — In both `generate()` and `chat()`:
|
||||
|
||||
1. Pre-request check: `if (options.signal?.aborted) throw ...`
|
||||
@ -91,9 +81,6 @@ in-progress HTTP requests.
|
||||
3. Per-chunk check at top of `for await` loop:
|
||||
`if (options.signal?.aborted) throw new DOMException("The operation was aborted", "AbortError")`
|
||||
|
||||
Additionally, `OllamaAiApi` overrides `abort()` to call `this.client.abort()`,
|
||||
which immediately terminates the underlying HTTP request to the Ollama server.
|
||||
|
||||
**`packages/ai/src/openai.ts`** — Same pattern in:
|
||||
|
||||
- `generate()` — pre-check, pass `signal` to SDK, per-chunk in `for await`
|
||||
@ -121,18 +108,7 @@ and is detected downstream via `error.name === "AbortError"`.
|
||||
```
|
||||
- `finally` block sets `this.abortController = null`
|
||||
- Public method `abortCurrentWorkOrder(): boolean` — calls
|
||||
`this.abortController?.abort()` then `AiService.abort()`, returns `true` if
|
||||
there was a controller. The `AiService.abort()` call provides provider-specific
|
||||
forceful abort (e.g., `OllamaAiApi.abort()` → `this.client.abort()`).
|
||||
|
||||
**`gadget-drone/src/services/ai.ts`**:
|
||||
|
||||
- Property: `private activeApi: AiApi | null = null` — tracks the most recently
|
||||
created `AiApi` instance
|
||||
- `getApi()` stores each created instance on `this.activeApi`
|
||||
- `chat()` and `generate()` clear `this.activeApi = null` in `finally` blocks
|
||||
- Public method `abort(): void` — calls `this.activeApi?.abort()`, then clears
|
||||
the reference
|
||||
`this.abortController?.abort()`, returns `true` if there was a controller
|
||||
|
||||
**`gadget-drone/src/gadget-drone.ts`**:
|
||||
|
||||
@ -217,11 +193,10 @@ status: "processing" | "finished" | "aborted" | "error";
|
||||
| `.gitignore` | Add root-level log file patterns |
|
||||
| `packages/api/src/messages/ide.ts` | New `AbortWorkOrderCallback`, `AbortWorkOrderMessage` |
|
||||
| `packages/api/src/messages/socket.ts` | Register `abortWorkOrder` in both event interfaces |
|
||||
| `packages/ai/src/api.ts` | `signal?: AbortSignal` on `IAiChatOptions`, `IAiGenerateOptions`; `abort()` on `AiApi` |
|
||||
| `packages/ai/src/ollama.ts` | Pre-request and per-chunk abort checks, signal pass-through; `abort()` override |
|
||||
| `packages/ai/src/api.ts` | `signal?: AbortSignal` on `IAiChatOptions`, `IAiGenerateOptions` |
|
||||
| `packages/ai/src/ollama.ts` | Pre-request and per-chunk abort checks, signal pass-through |
|
||||
| `packages/ai/src/openai.ts` | Same for `generate()`, `readStreamingChatCompletion()`, `readNonStreamingChatCompletion()` |
|
||||
| `gadget-drone/src/services/agent.ts` | `AbortController` property, abort detection, `abortCurrentWorkOrder()` calls `AiService.abort()` |
|
||||
| `gadget-drone/src/services/ai.ts` | Track active `AiApi` instance, `abort()` method, cleanup in `finally` blocks |
|
||||
| `gadget-drone/src/services/agent.ts` | `AbortController` property, abort detection, `abortCurrentWorkOrder()` |
|
||||
| `gadget-drone/src/gadget-drone.ts` | `onAbortWorkOrder` socket handler |
|
||||
| `gadget-code/src/lib/code-session.ts` | Forward `abortWorkOrder` to drone |
|
||||
| `gadget-code/src/lib/drone-session.ts` | Detect `"aborted"` in `workOrderComplete` |
|
||||
@ -239,16 +214,9 @@ status: "processing" | "finished" | "aborted" | "error";
|
||||
event or change the `workOrderComplete` signature.
|
||||
- The `AbortController` is created per-process() call and cleaned up in
|
||||
`finally`. It is never shared across work orders.
|
||||
- The abort callback returns immediately after the controller is signaled and
|
||||
`AiService.abort()` is called. The AbortError propagates asynchronously
|
||||
through the SDK stream and is caught separately in the agent loop.
|
||||
- `AiService.abort()` provides provider-specific forceful abort (e.g., Ollama's
|
||||
`client.abort()` terminates the HTTP request immediately). This is called
|
||||
_in addition to_ the `AbortController.abort()` signal so that both the
|
||||
AbortSignal chain and the provider-specific abort mechanism are triggered.
|
||||
- The active `AiApi` instance is tracked on `AiService.activeApi` and is set to
|
||||
`null` in the `finally` block of each `chat()`/`generate()` call, so the
|
||||
reference is never stale.
|
||||
- The abort callback returns immediately after the controller is signaled. The
|
||||
AbortError propagates asynchronously through the SDK stream and is caught
|
||||
separately in the agent loop.
|
||||
- Subagent loops also receive the abort signal (via `this.abortController?.signal`)
|
||||
and abort naturally — no special subagent abort handling needed.
|
||||
- The Esc handler is a global `window` keydown listener. It is registered ONLY
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The Agent Toolbox (`@gadget/ai-toolbox`) is a shared package providing AI agent tool implementations for the Gadget platform. It was extracted from `@gadget/ai` into its own package so that agent tools can be maintained independently of the AI provider abstraction layer.
|
||||
The Agent Toolbox is a foundational component of the Gadget platform that enables AI agents to perform real-world actions through a standardized, extensible tool system. Tools are functions that agents can invoke to interact with external services, manipulate files, search the web, and execute various operations.
|
||||
|
||||
**Consumers:** `gadget-drone` (the only current consumer). gadget-tasks does **not** use `@gadget/ai-toolbox` — it drives the gadget-code platform via REST API and Socket.IO as a headless IDE client, delegating all AI and tool execution to drones.
|
||||
## Architecture
|
||||
|
||||
### Design Philosophy
|
||||
|
||||
@ -15,14 +15,13 @@ The toolbox system is built on these core principles:
|
||||
3. **Extensibility**: Easy to add new tools without modifying core infrastructure
|
||||
4. **Security**: Tools require explicit credentials configured in the environment
|
||||
5. **Error Handling**: Comprehensive error reporting with recovery hints
|
||||
6. **Shared Implementation**: One tool codebase used by all agent processes
|
||||
|
||||
### Component Overview
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ AiToolbox │ ← Manages tool registration and lookup
|
||||
│ - env │ ← Holds GadgetToolboxEnvironment with credentials
|
||||
│ - env │ ← Holds IAiEnvironment with credentials
|
||||
│ - tools │ ← Map of all registered tools
|
||||
│ - modeSets │ ← Tools organized by ChatSessionMode
|
||||
└────────┬────────┘
|
||||
@ -30,7 +29,7 @@ The toolbox system is built on these core principles:
|
||||
│ register()
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ GadgetTool │ ← Abstract base class for all tools
|
||||
│ AiTool │ ← Abstract base class for all tools
|
||||
│ - name │ ← Unique tool identifier
|
||||
│ - category │ ← Tool category (search, file, etc.)
|
||||
│ - definition │ ← JSON Schema for AI provider
|
||||
@ -42,21 +41,19 @@ The toolbox system is built on these core principles:
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ GoogleSearch │ ← Example tool implementation
|
||||
│ FileReadTool │ ← Read files from the workspace
|
||||
│ ShellTool │ ← Execute shell commands
|
||||
│ SubagentTool │ ← Spawn subagents for tasks
|
||||
│ GrepTool │ ← Future tools...
|
||||
│ ... │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Core Interfaces
|
||||
|
||||
### GadgetToolboxEnvironment
|
||||
### IAiEnvironment
|
||||
|
||||
The `GadgetToolboxEnvironment` interface carries configuration and credentials from the application layer (which has access to YAML configs) down to the `@gadget/ai-toolbox` package (which cannot read configs directly). It also carries workspace and project context that tools need.
|
||||
The `IAiEnvironment` interface carries configuration and credentials from the application layer (which has access to YAML configs) down to the `@gadget/ai` package (which cannot read configs directly).
|
||||
|
||||
```typescript
|
||||
export interface GadgetToolboxEnvironment {
|
||||
export interface IAiEnvironment {
|
||||
NODE_ENV: string;
|
||||
services?: {
|
||||
google?: {
|
||||
@ -65,24 +62,24 @@ export interface GadgetToolboxEnvironment {
|
||||
engineId?: string;
|
||||
};
|
||||
};
|
||||
github?: {
|
||||
token?: string;
|
||||
};
|
||||
slack?: {
|
||||
token?: string;
|
||||
signingSecret?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
workspace?: {
|
||||
workspaceDir?: string;
|
||||
projectDir?: string;
|
||||
cacheDir?: string;
|
||||
};
|
||||
project?: IProject;
|
||||
chatSessionMode?: ChatSessionMode;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Design Decisions:**
|
||||
|
||||
- **Services Object**: Credentials are organized by service (google, etc.)
|
||||
- **Services Object**: Credentials are organized by service (google, github, slack, etc.)
|
||||
- **Optional Everything**: All fields are optional to support partial configurations
|
||||
- **Workspace Context**: Tools that work with files need `workspaceDir`, `projectDir`, and `cacheDir`
|
||||
- **Project Context**: Tools can access the current `IProject` and `ChatSessionMode`
|
||||
- **Extensible**: Future services can be added without breaking changes
|
||||
- **Extensible**: The `[key: string]: unknown` index signature allows future services without breaking changes
|
||||
- **Type Safety**: Known services have typed interfaces
|
||||
|
||||
### AiToolbox
|
||||
|
||||
@ -90,28 +87,19 @@ The toolbox manages tool registration, organization, and retrieval:
|
||||
|
||||
```typescript
|
||||
export class AiToolbox {
|
||||
constructor(env: GadgetToolboxEnvironment);
|
||||
constructor(env: IAiEnvironment);
|
||||
|
||||
// Register a tool for use by agents
|
||||
register(tool: IAiTool, modes?: string[]): void;
|
||||
register(tool: AiTool, modes?: string[]): void;
|
||||
|
||||
// Get a tool by name (for system tools)
|
||||
getTool(name: string): IAiTool | undefined;
|
||||
getTool(name: string): AiTool | undefined;
|
||||
|
||||
// Get all tools for a specific mode
|
||||
getModeSet(mode: string): ToolSet | undefined;
|
||||
|
||||
// Get tool names for a specific mode
|
||||
getToolNamesForMode(mode: string): string[];
|
||||
|
||||
// Update workspace context (called when project changes)
|
||||
updateWorkspace(workspace: { workspaceDir?: string; projectDir?: string; cacheDir?: string }): void;
|
||||
|
||||
// Update project context (called when session mode changes)
|
||||
updateProjectContext(project: IProject, mode: ChatSessionMode): void;
|
||||
|
||||
// Access environment credentials
|
||||
get env(): GadgetToolboxEnvironment;
|
||||
get env(): IAiEnvironment;
|
||||
}
|
||||
```
|
||||
|
||||
@ -120,18 +108,14 @@ export class AiToolbox {
|
||||
- **System Tools** (no modes): Called by the platform itself (e.g., auto-naming chat sessions)
|
||||
- **Agent Tools** (with modes): Available to AI agents in specific ChatSessionModes (e.g., "code", "research", "debug")
|
||||
|
||||
### GadgetTool
|
||||
### AiTool
|
||||
|
||||
All tools extend this abstract base class:
|
||||
|
||||
```typescript
|
||||
export abstract class GadgetTool implements IAiTool {
|
||||
export abstract class AiTool {
|
||||
protected _toolbox: AiToolbox;
|
||||
|
||||
constructor(toolbox: AiToolbox);
|
||||
|
||||
get toolbox(): AiToolbox;
|
||||
|
||||
// Unique identifier for the tool
|
||||
abstract get name(): string;
|
||||
|
||||
@ -171,9 +155,9 @@ The tool execution flow demonstrates the abstraction layer between the common to
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Application Layer (gadget-drone) │
|
||||
│ Application Layer (gadget-drone / gadget-code) │
|
||||
│ - Reads YAML config │
|
||||
│ - Constructs GadgetToolboxEnvironment │
|
||||
│ - Constructs IAiEnvironment │
|
||||
│ - Creates AiToolbox │
|
||||
│ - Registers tools │
|
||||
└────────────────────┬────────────────────────────────────────┘
|
||||
@ -309,6 +293,16 @@ This allows complex multi-step operations where the AI can:
|
||||
|
||||
Tools requiring credentials need them configured in your YAML config files.
|
||||
|
||||
#### gadget-code.yaml
|
||||
|
||||
```yaml
|
||||
# Add to gadget-code.yaml
|
||||
google:
|
||||
cse:
|
||||
apiKey: "${GOOGLE_CSE_API_KEY}"
|
||||
engineId: "${GOOGLE_CSE_ENGINE_ID}"
|
||||
```
|
||||
|
||||
#### gadget-drone.yaml
|
||||
|
||||
```yaml
|
||||
@ -319,8 +313,6 @@ google:
|
||||
engineId: "${GOOGLE_CSE_ENGINE_ID}"
|
||||
```
|
||||
|
||||
> **Note:** `gadget-code` and `gadget-tasks` do not need toolbox credentials. gadget-code builds prompts but doesn't execute tools directly. gadget-tasks is a headless IDE client that delegates all tool execution to drones via the platform.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set the actual values in your shell or deployment environment:
|
||||
@ -332,14 +324,14 @@ export GOOGLE_CSE_ENGINE_ID="your-engine-id-here"
|
||||
|
||||
### TypeScript Usage
|
||||
|
||||
In consumer applications, construct the environment and pass to the toolbox:
|
||||
In consumer applications, construct the environment and pass to the AI API:
|
||||
|
||||
```typescript
|
||||
// gadget-drone/src/services/ai.ts
|
||||
import { AiToolbox, GadgetToolboxEnvironment } from "@gadget/ai-toolbox";
|
||||
import { createAiApi, IAiEnvironment } from "@gadget/ai";
|
||||
import env from "../config/env.js";
|
||||
|
||||
const toolboxEnv: GadgetToolboxEnvironment = {
|
||||
const aiEnv: IAiEnvironment = {
|
||||
NODE_ENV: env.NODE_ENV,
|
||||
services: {
|
||||
google: {
|
||||
@ -351,12 +343,7 @@ const toolboxEnv: GadgetToolboxEnvironment = {
|
||||
},
|
||||
};
|
||||
|
||||
const toolbox = new AiToolbox(toolboxEnv);
|
||||
|
||||
// Register tools for specific chat session modes
|
||||
toolbox.register(new GoogleSearchTool(toolbox), ["code", "research"]);
|
||||
toolbox.register(new FileReadTool(toolbox), ["code", "plan", "build"]);
|
||||
toolbox.register(new SubagentTool(toolbox), ["code"]);
|
||||
const api = createAiApi(aiEnv, providerConfig, logger);
|
||||
```
|
||||
|
||||
## Example Tool: Google Search
|
||||
@ -463,7 +450,7 @@ Tool Call: search_google({
|
||||
|
||||
### Implementation Details
|
||||
|
||||
**File**: `packages/ai-toolbox/src/network/search-google.ts`
|
||||
**File**: `packages/ai/src/tools/search/google.ts`
|
||||
|
||||
**Key Methods:**
|
||||
|
||||
@ -573,19 +560,14 @@ RECOVERY HINT: Provide a 'query' parameter with your search terms and try again.
|
||||
|
||||
### Step 1: Create Tool Class
|
||||
|
||||
Extend `GadgetTool` and implement the required methods:
|
||||
Extend `AiTool` and implement the required methods:
|
||||
|
||||
```typescript
|
||||
import { GadgetTool } from "@gadget/ai-toolbox";
|
||||
import type { AiToolbox } from "@gadget/ai-toolbox";
|
||||
import type { IToolArguments, IToolDefinition, IAiLogger } from "@gadget/ai";
|
||||
import { formatError } from "@gadget/ai";
|
||||
|
||||
export class MyNewTool extends GadgetTool {
|
||||
constructor(toolbox: AiToolbox) {
|
||||
super(toolbox);
|
||||
}
|
||||
import { AiTool, IToolArguments, IToolDefinition } from "../tool.js";
|
||||
import { IAiLogger } from "../../api.js";
|
||||
import { formatError } from "../tool-error.js";
|
||||
|
||||
export class MyNewTool extends AiTool {
|
||||
get name(): string {
|
||||
return "my_tool_name";
|
||||
}
|
||||
@ -634,14 +616,11 @@ export class MyNewTool extends GadgetTool {
|
||||
throw new Error("API key not configured in environment");
|
||||
}
|
||||
|
||||
// Access workspace context
|
||||
const projectDir = this.toolbox.env.workspace?.projectDir;
|
||||
|
||||
// Perform the operation
|
||||
logger.debug("executing my tool", { args });
|
||||
|
||||
try {
|
||||
const result = await this.doSomething(args.param1 as string);
|
||||
const result = await this.doSomething(args.param1);
|
||||
return `Operation successful: ${result}`;
|
||||
} catch (error) {
|
||||
return formatError({
|
||||
@ -660,10 +639,10 @@ export class MyNewTool extends GadgetTool {
|
||||
|
||||
### Step 2: Add Credentials (if needed)
|
||||
|
||||
Update `GadgetToolboxEnvironment` in `packages/ai-toolbox/src/toolbox.ts`:
|
||||
Update `IAiEnvironment` in `packages/ai/src/config/env.ts`:
|
||||
|
||||
```typescript
|
||||
export interface GadgetToolboxEnvironment {
|
||||
export interface IAiEnvironment {
|
||||
NODE_ENV: string;
|
||||
services?: {
|
||||
google?: {
|
||||
@ -676,34 +655,40 @@ export interface GadgetToolboxEnvironment {
|
||||
apiKey?: string;
|
||||
endpoint?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
workspace?: {
|
||||
workspaceDir?: string;
|
||||
projectDir?: string;
|
||||
cacheDir?: string;
|
||||
};
|
||||
project?: IProject;
|
||||
chatSessionMode?: ChatSessionMode;
|
||||
}
|
||||
```
|
||||
|
||||
Update config types in `packages/config/src/types.ts` and consumer config readers to populate the environment.
|
||||
Update config types in `packages/config/src/types.ts`:
|
||||
|
||||
```typescript
|
||||
export interface GadgetDroneConfig {
|
||||
// ... existing fields
|
||||
myService?: {
|
||||
apiKey: string;
|
||||
endpoint: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Update consumer config readers to populate the environment.
|
||||
|
||||
### Step 3: Register the Tool
|
||||
|
||||
In gadget-drone startup code:
|
||||
In gadget-drone startup code (to be implemented):
|
||||
|
||||
```typescript
|
||||
import { AiToolbox } from "@gadget/ai-toolbox";
|
||||
import { MyNewTool } from "@gadget/ai-toolbox";
|
||||
import { AiToolbox } from "@gadget/ai";
|
||||
import { MyNewTool } from "@gadget/ai/tools/my-tool.js";
|
||||
|
||||
const toolbox = new AiToolbox(toolboxEnv);
|
||||
const toolbox = new AiToolbox(aiEnv);
|
||||
|
||||
// Register as system tool (no modes — called by the platform itself)
|
||||
toolbox.register(new MyNewTool(toolbox));
|
||||
// Register as system tool (no modes)
|
||||
toolbox.register(new MyNewTool());
|
||||
|
||||
// Or register for specific chat session modes
|
||||
toolbox.register(new MyNewTool(toolbox), ["code", "debug"]);
|
||||
// Or register for specific modes
|
||||
toolbox.register(new MyNewTool(), ["code", "debug"]);
|
||||
```
|
||||
|
||||
## Testing Tools
|
||||
@ -758,7 +743,7 @@ import { AiToolbox } from "../src/toolbox.js";
|
||||
|
||||
describe("GoogleSearchTool integration", () => {
|
||||
it("should call Google CSE API with correct parameters", async () => {
|
||||
const env: GadgetToolboxEnvironment = {
|
||||
const env = {
|
||||
NODE_ENV: "test",
|
||||
services: {
|
||||
google: {
|
||||
@ -868,21 +853,8 @@ Planned improvements to the toolbox system:
|
||||
4. **Tool Metadata**: Version, author, usage statistics
|
||||
5. **Result Streaming**: Stream large results back to AI
|
||||
|
||||
## Tool Categories
|
||||
|
||||
The `@gadget/ai-toolbox` package organizes tools into the following categories:
|
||||
|
||||
| Category | Directory | Tools | Description |
|
||||
|----------|-----------|-------|-------------|
|
||||
| Network | `src/network/` | `GoogleSearchTool`, `FetchUrlTool`, `WebFetcherTool` | Web search and URL fetching |
|
||||
| System | `src/system/` | `FileReadTool`, `FileWriteTool`, `FileEditTool`, `FileListTool`, `GlobTool`, `GrepTool`, `ShellTool` | File system and shell operations |
|
||||
| Plan | `src/plan/` | `PlanFileReadTool`, `PlanFileWriteTool`, `PlanFileEditTool`, `PlanFileListTool` | Plan-mode file operations |
|
||||
| Chat | `src/chat/` | `SubagentTool` | Subagent spawning for multi-agent workflows |
|
||||
| Project | `src/project/` | `ListSkillsTool`, `ReadSkillTool` | Project skill access |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Configuration Guide](./configuration.md) - Setting up tool credentials
|
||||
- [Architecture Overview](./architecture.md) - System architecture
|
||||
- [AI API Reference](../packages/ai/README.md) - `@gadget/ai` package documentation
|
||||
- [gadget-tasks Documentation](./gadget-tasks.md) - Scheduled task worker (does NOT use @gadget/ai-toolbox)
|
||||
|
||||
@ -1,220 +0,0 @@
|
||||
# gadget-tasks Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
gadget-tasks is the Gadget Code **scheduled task worker** — a headless IDE client that automates the browser IDE flow on a cron schedule. It drives the gadget-code platform via REST API and Socket.IO, using the exact same protocol the browser IDE uses, but without any UI.
|
||||
|
||||
**Key design principle:** gadget-tasks contains **zero duplicated code**. No Mongoose models, no AI API calls, no prompt templates, no workspace management. All of that flows through the gadget-code platform, which delegates to drones for execution.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ gadget-tasks │────▶│ gadget-code │────▶│ gadget-drone │
|
||||
│ (Headless IDE) │◀────│ (Platform) │◀────│ (AWL Worker) │
|
||||
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
||||
│ │ │
|
||||
│ REST API │ MongoDB │ Files
|
||||
│ Socket.IO │ Redis │ Git
|
||||
│ JWT Auth │ Socket.IO relay │ AI API
|
||||
```
|
||||
|
||||
gadget-tasks acts as a **programmatic IDE user**:
|
||||
- Authenticates with the platform (same as logging into the browser IDE)
|
||||
- Selects a drone (same as clicking a drone in the UI)
|
||||
- Creates chat sessions, locks drones, submits prompts (same as typing in the chat)
|
||||
- Waits for work order completion (same as watching the streaming response)
|
||||
- Updates task records when done
|
||||
|
||||
## Per-Task Execution Flow
|
||||
|
||||
When a CronJob fires for a scheduled task:
|
||||
|
||||
1. **Create ChatSession** — `POST /api/v1/chat-sessions`
|
||||
2. **Lock drone** — Socket.IO `requestSessionLock(drone, project, session)`
|
||||
3. **Set workspace mode** — Socket.IO `requestWorkspaceMode(drone, project, session, "agent")`
|
||||
4. **Submit prompt** — Socket.IO `submitPrompt(task.content)` → creates ChatTurn with canonical system prompt → routes work order to drone
|
||||
5. **Wait for completion** — Socket.IO receives `workOrderComplete(turnId, success, message)`
|
||||
6. **Release drone lock** — Socket.IO `releaseSessionLock(drone, project, session)`
|
||||
7. **Update task.lastRun** — `PATCH /api/v1/projects/:id/tasks/:taskId/lastRun`
|
||||
|
||||
Steps 2–6 use the **same Socket.IO protocol** the browser IDE uses. gadget-code builds the system prompt from its canonical templates, routes the work order to the drone, and persists all results as ChatTurn records — identical to interactive use.
|
||||
|
||||
## Source Structure
|
||||
|
||||
```
|
||||
gadget-tasks/
|
||||
├── gadget-tasks.yaml # YAML configuration
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── src/
|
||||
├── gadget-tasks.ts # Main entry — startup, shutdown, drone selection
|
||||
├── config/
|
||||
│ └── env.ts # Config loader — platform.baseUrl, redis, concurrency
|
||||
├── lib/
|
||||
│ ├── process.ts # GadgetProcess base class
|
||||
│ └── service.ts # GadgetService base class
|
||||
└── services/
|
||||
├── platform.ts # REST API + Socket.IO headless IDE client
|
||||
├── scheduler.ts # CronJob management, concurrency control
|
||||
└── lock.ts # Redis singleton lock (prevents duplicate instances)
|
||||
```
|
||||
|
||||
### Key Services
|
||||
|
||||
| Service | File | Purpose |
|
||||
|---------|------|---------|
|
||||
| **PlatformService** | `src/services/platform.ts` | REST API client (auth, projects, sessions, drones) + Socket.IO client (session lock, workspace mode, prompt submission, work order tracking) |
|
||||
| **SchedulerService** | `src/services/scheduler.ts` | Creates CronJobs from task crontab expressions, enforces concurrency limit, delegates to PlatformService |
|
||||
| **TaskLockService** | `src/services/lock.ts` | Redis-based singleton lock — prevents multiple gadget-tasks instances from running simultaneously |
|
||||
|
||||
## Configuration
|
||||
|
||||
### gadget-tasks.yaml
|
||||
|
||||
```yaml
|
||||
timezone: America/New_York
|
||||
platform:
|
||||
baseUrl: https://code-dev.g4dge7.com:5174
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
# password: optional
|
||||
# keyPrefix: defaults to "gadget:"
|
||||
concurrency: 1
|
||||
logging:
|
||||
console:
|
||||
enabled: true
|
||||
file:
|
||||
enabled: true
|
||||
path: ~/logs/gadget-tasks
|
||||
# name: defaults to "gadget-tasks"
|
||||
# maxWritesPerFile: defaults to 10000
|
||||
# maxFiles: defaults to 10
|
||||
```
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `timezone` | No | `America/New_York` | Timezone for CronJob scheduling |
|
||||
| `platform.baseUrl` | **Yes** | — | URL of the gadget-code platform |
|
||||
| `redis.host` | No | `localhost` | Redis host for singleton lock |
|
||||
| `redis.port` | No | `6379` | Redis port |
|
||||
| `redis.password` | No | — | Redis password |
|
||||
| `redis.keyPrefix` | No | `gadget:` | Redis key prefix |
|
||||
| `concurrency` | No | `1` | Max concurrent tasks (sequential by default) |
|
||||
| `logging.console.enabled` | No | `false` | Enable console logging |
|
||||
| `logging.file.enabled` | No | `false` | Enable file logging |
|
||||
| `logging.file.path` | No | `./logs` | Log file directory |
|
||||
|
||||
### Credentials
|
||||
|
||||
Email and password are **not stored in the config file**. They are provided at startup:
|
||||
|
||||
- **CLI args:** `--user=admin@example.com --password=secret`
|
||||
- **Interactive prompt:** If no CLI args, you'll be prompted (same pattern as gadget-drone)
|
||||
|
||||
This avoids storing credentials in config files that may be checked into version control.
|
||||
|
||||
## Startup Sequence
|
||||
|
||||
```
|
||||
1. hookProcessSignals() — SIGINT handler
|
||||
2. Acquire Redis singleton lock — exits if another instance is running
|
||||
3. Get user credentials — CLI args or interactive prompt
|
||||
4. Authenticate with platform — POST /api/v1/auth/sign-in → JWT
|
||||
5. Select a drone — auto-select if only one, otherwise interactive list
|
||||
6. Connect Socket.IO — JWT auth, websocket transport
|
||||
7. Start scheduler — empty initially
|
||||
8. Fetch projects — GET /api/v1/projects
|
||||
9. Schedule enabled tasks — create CronJob for each task's crontab
|
||||
10. Start heartbeat — 19s interval, prevents drone timeout
|
||||
```
|
||||
|
||||
## Shutdown Sequence
|
||||
|
||||
```
|
||||
1. Stop all CronJobs — SchedulerService.stop()
|
||||
2. Disconnect Socket.IO — reject pending work orders
|
||||
3. Release Redis lock — allow another instance to start
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- gadget-code platform running (backend + frontend)
|
||||
- At least one gadget-drone registered and online
|
||||
- Redis running on localhost:6379 (or as configured)
|
||||
- A user account on the platform with projects that have tasks defined
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# From the monorepo root
|
||||
cd gadget-tasks
|
||||
|
||||
# Development mode (TypeScript, auto-restart)
|
||||
pnpm dev -- --user=admin@example.com --password=secret
|
||||
|
||||
# Or with interactive credential prompt
|
||||
pnpm dev
|
||||
|
||||
# Production (build first)
|
||||
pnpm build
|
||||
pnpm start -- --user=admin@example.com --password=secret
|
||||
```
|
||||
|
||||
### Global CLI
|
||||
|
||||
```bash
|
||||
# Link globally from the monorepo root
|
||||
pnpm link:global
|
||||
|
||||
# Now available as a system command
|
||||
gadget-tasks --user=admin@example.com --password=secret
|
||||
```
|
||||
|
||||
## Task Execution Details
|
||||
|
||||
### Concurrency
|
||||
|
||||
By default, `concurrency: 1` means tasks execute **sequentially**. If a task fires while the drone is busy with another task, the new task waits for the drone to become available. This is the correct model — drones process one work order at a time.
|
||||
|
||||
If you increase `concurrency`, you need multiple drones available. Each task requires a drone lock, and a drone can only be locked to one session at a time.
|
||||
|
||||
### Work Order Tracking
|
||||
|
||||
When `submitPrompt` is called, the callback provides a `turnId`. gadget-tasks stores a Promise resolver in a `pendingWorkOrders` map keyed by `turnId`. When the server emits `workOrderComplete(turnId, success, message)`, the corresponding Promise is resolved, unblocking the task execution.
|
||||
|
||||
### Session Heartbeat
|
||||
|
||||
gadget-tasks sends a `sessionHeartbeat` every 19 seconds (same interval as the browser IDE). This prevents the drone's 120-second heartbeat timeout from firing while a task is active.
|
||||
|
||||
### Error Recovery
|
||||
|
||||
If gadget-tasks crashes while a task is being processed:
|
||||
- The drone continues processing the work order independently
|
||||
- The session and ChatTurn records are preserved in the database
|
||||
- On restart, gadget-tasks creates **new** sessions for tasks that fire
|
||||
- Old sessions are visible in the browser IDE via their ChatTurn records
|
||||
|
||||
No special crash recovery is needed in gadget-tasks (unlike gadget-drone, which has crash recovery for incomplete work orders).
|
||||
|
||||
## What gadget-tasks Does NOT Do
|
||||
|
||||
| ❌ Does NOT | ✅ Instead |
|
||||
|------------|----------|
|
||||
| Connect to MongoDB | Uses REST API to read/write data |
|
||||
| Define Mongoose models | Uses `@gadget/api` TypeScript interfaces |
|
||||
| Call AI APIs | Submits prompts through the platform → drone |
|
||||
| Build system prompts | gadget-code builds prompts from canonical templates |
|
||||
| Execute tools | The drone executes tools via `@gadget/ai-toolbox` |
|
||||
| Manage workspaces | The drone manages workspace directories |
|
||||
| Store credentials in config | Provides them at startup via CLI or prompt |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [README](../README.md) — Project overview and quick start
|
||||
- [Agent Toolbox](./agent-toolbox.md) — `@gadget/ai-toolbox` tool implementations (used by drones, not gadget-tasks)
|
||||
- [Socket Protocol](./socket-protocol.md) — Socket.IO protocol between IDE, platform, and drone
|
||||
- [Drone Documentation](../gadget-drone/docs/gadget-drone.md) — How drones execute work orders
|
||||
@ -6,22 +6,6 @@ The Landing Page project will be building a static website with a sales and mark
|
||||
|
||||
Gadget Code is free and open source software licensed under the Apache 2.0 open source license. There is no cost to obtain Gadget Code. We will be providing a link directly to our self-hosted gitea-backed server. It is not on GitHub. It prefers to stay away from GitHub.
|
||||
|
||||
## Landing Page Project
|
||||
|
||||
The landing page project will be named `gadget-public`, and will be added to the monorepo as a peer of `gadget-code` and `gadget-drone`. Eventually, the `gadget-extension` project will be migrated into this monorepo. And that will happen very soon.
|
||||
|
||||
For now, you will build the `gadget-public` project in the monorepo patterned after the [](../gadget-code/frontend/) project.
|
||||
|
||||
Take note of how dependencies are managed from the root directory using commands such as `pnpm --filter=gadget-code add [package] [package] ...`. We should strive for parity with the gadget-code:frontend in terms of look, feel, theme, presentation, design, and functionality. BUT, this is a static page. There is no sign-up/sign-in. People get the software product. And they will sign up/subscribe from within the application.
|
||||
|
||||
The Landing Page project is only leading people to the software. They will either want to install the Gadget browser extension, or install and configure Gadget Code. And yes, this Landing Page public project will host our documentation.
|
||||
|
||||
The site will have a placeholder for a video that the visitor can play. I have provided a [placeholder video file](./temp/gadget-home.mp4). You can move that file into place when ready for it. I'm using ./temp as a dropbox for you.
|
||||
|
||||
## Gadget (Browser Sidebar Extension)
|
||||
|
||||
The Gadget browser sidebar extension is documented in [Gadget Extension](./temp/gadget-design.md). Refer to that document for a complete description of the extension.
|
||||
|
||||
## The Status of GitHub and Why We Avoid It
|
||||
|
||||
GitHub is experiencing a severe platform reliability and security crisis in 2026, driven by a massive surge in AI agent traffic and infrastructure strain. Third-party monitoring services report that GitHub's actual uptime plummeted to roughly 90.21% over a 90-day window, experiencing 37 service incidents in February and 48 major outages between mid-2025 and April 2026. [1, 2, 3]
|
||||
|
||||
@ -13,7 +13,7 @@ Gadget Code is a self-hosted, open source, Enterprise-tier Agentic Integrated De
|
||||
|
||||
A variety of people will use Gadget Code in a variety of ways to work primarily on software projects, but also other tasks and goals.
|
||||
|
||||
- **Project managers** will have the kind of insight that's simply impossble to have in other systems that can't have a conversation about what's going on inside of them.
|
||||
- **Project managers** will have the kind of insight that's simply impoisslbe to have in other systems that can't have a conversation about what's going on inside of them.
|
||||
- **Developers** will edit code by hand, or by describing intent to their hand-tuned agents. Plural.
|
||||
- **Quality Assurance (QA)** professionals receive a dedicated MODE for developing and executuing tests, documentting bugs, or (sometimes) using Gadget to simply fix them and move on.
|
||||
- **Operations (NOC, DevOps, etc.)** also receives a dedicated mode for orchestrating and automating your hosting infrastructure, securing it, hardening it, monitoring it, and scaling it.
|
||||
|
||||
@ -14,15 +14,12 @@ This document serves as a "Cheat Sheet" for AI agents and developers working on
|
||||
|
||||
## 2. Event Map Overview
|
||||
|
||||
Defined in `packages/api/src/messages/socket.ts`. Additional types in `packages/api/src/messages/subprocess.ts`.
|
||||
Defined in `packages/api/src/messages/socket.ts`.
|
||||
|
||||
### IDE -> Web (Client to Server)
|
||||
* `requestSessionLock`: Request to exclusive-lock a drone for a project session.
|
||||
* `requestWorkspaceMode`: Request a mode change (Idle, User, Agent).
|
||||
* `submitPrompt`: Submit a user prompt for agent processing.
|
||||
* `requestProcessStats`: Request subprocess stats from a drone by registration ID (no session lock required).
|
||||
* `subscribeDrone`: Subscribe to live log/status events from a drone (used by DroneManager/DroneInspector).
|
||||
* `unsubscribeDrone`: Unsubscribe from live drone events.
|
||||
|
||||
### Drone -> Web (Client to Server)
|
||||
* `thinking`: Stream reasoning/thought process text.
|
||||
@ -36,12 +33,9 @@ Defined in `packages/api/src/messages/socket.ts`. Additional types in `packages/
|
||||
* `processWorkOrder`: Command to start processing a specific prompt/turn.
|
||||
* `crashRecoveryResponse`: Command to `discard` or `retry` a stalled work order.
|
||||
* `requestTermination`: Command to immediately terminate the drone process.
|
||||
* `requestProcessStats`: Request subprocess stats from the drone (responds with `SubProcessStat[]` via callback).
|
||||
|
||||
### Web -> IDE (Server to Client)
|
||||
* `sessionUpdated`: Notify the IDE that a chat session property has changed (e.g. auto-generated name).
|
||||
* `drone:log`: Broadcast log entry from a drone to monitoring IDE sessions (DroneManager, DroneInspector).
|
||||
* `drone:status`: Broadcast status update from a drone to monitoring IDE sessions.
|
||||
|
||||
---
|
||||
|
||||
@ -77,25 +71,7 @@ Defined in `packages/api/src/messages/socket.ts`. Additional types in `packages/
|
||||
* Forwards event to **IDE**.
|
||||
* Clears `currentTurnId` from the drone session.
|
||||
|
||||
### 3.4 SubProcess Monitoring Flow
|
||||
|
||||
1. **IDE** selects a drone in DroneManager or DroneInspector.
|
||||
2. **IDE** emits `subscribeDrone(registrationId)` → backend registers socket in `SocketService.droneMonitorIndex`.
|
||||
3. **IDE** starts a 1-second `setInterval` emitting `requestProcessStats(registrationId, cb)`.
|
||||
4. **Web (`CodeSession.ts`)**:
|
||||
* Looks up `DroneSession` via `SocketService.getDroneSessionByRegistrationId()` (no chat session required).
|
||||
* Forwards to drone: `droneSession.socket.emit("requestProcessStats", cb)`.
|
||||
5. **Drone (`gadget-drone.ts`)**:
|
||||
* Calls `SubProcessService.ps()` → `summarize()` → builds `SubProcessStat[]`.
|
||||
* Calls callback `cb(true, { processes })`.
|
||||
6. **IDE** receives `SubProcessStat[]` → renders `<SubProcessTable>` + updates process status.
|
||||
7. **Log streaming** (automatic, no polling):
|
||||
* Drone emits `log(timestamp, component, level, message)` as usual.
|
||||
* `DroneSession.onLog()` also broadcasts `drone:log` to all monitors via `SocketService.broadcastToMonitors()`.
|
||||
* IDE receives `drone:log` events → appends to log state → renders via `<LogRenderer>`.
|
||||
8. **Cleanup**: IDE emits `unsubscribeDrone(registrationId)` on unmount/deselect; interval cleared.
|
||||
|
||||
### 3.5 Drone Termination Flow
|
||||
### 3.4 Drone Termination Flow
|
||||
1. **User** clicks "Terminate" button in Drone Manager UI.
|
||||
2. **IDE** calls `POST /api/v1/drone/registration/:id/terminate`.
|
||||
3. **Web (`DroneService.ts`)**:
|
||||
@ -175,60 +151,6 @@ type RequestTerminationMessage = (
|
||||
) => void;
|
||||
```
|
||||
|
||||
### Drone Monitor Events (Phase 2)
|
||||
```typescript
|
||||
// Defined in packages/api/src/messages/subprocess.ts
|
||||
interface SubProcessStat {
|
||||
pid: number;
|
||||
command: string;
|
||||
args: string[];
|
||||
projectId: GadgetId;
|
||||
projectSlug: string;
|
||||
projectName: string;
|
||||
status: "running" | "stopped" | "error";
|
||||
createdAt: string; // ISO 8601
|
||||
updatedAt: string; // ISO 8601
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
|
||||
// IDE -> Web (request/response with callback)
|
||||
type RequestProcessStatsMessage = (
|
||||
registrationId: string,
|
||||
cb: (success: boolean, data?: { processes?: SubProcessStat[]; message?: string }) => void
|
||||
) => void;
|
||||
|
||||
// Web -> Drone
|
||||
type RequestProcessStatsMessageDrone = (
|
||||
cb: (success: boolean, data?: { processes?: SubProcessStat[]; message?: string }) => void
|
||||
) => void;
|
||||
|
||||
// Monitor subscribe/unsubscribe
|
||||
type SubscribeDroneMessage = (
|
||||
registrationId: string,
|
||||
cb: (success: boolean) => void
|
||||
) => void;
|
||||
|
||||
type UnsubscribeDroneMessage = (
|
||||
registrationId: string,
|
||||
cb: (success: boolean) => void
|
||||
) => void;
|
||||
|
||||
// Web -> IDE (broadcast)
|
||||
type DroneLogBroadcast = (data: {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
component: string;
|
||||
message: string;
|
||||
metadata?: unknown;
|
||||
}) => void;
|
||||
|
||||
type DroneStatusBroadcast = (data: {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
}) => void;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Session Implementation Guide (Web Server)
|
||||
@ -255,9 +177,6 @@ The `SocketService` maintains multiple indexes for efficient session lookup:
|
||||
3. **`codeSessions`**: Map<socket.id, CodeSession> - Primary storage by socket ID
|
||||
4. **`codeSessionUserIndex`**: Map<user._id, CodeSession> - Lookup by user ID
|
||||
5. **`chatSessionIndex`**: Map<chatSessionId, CodeSession> - Reverse lookup from chat session to IDE
|
||||
6. **`droneMonitorIndex`**: Map<registration._id, Set<socket.id>> - Monitor subscriptions for log/status broadcast (Phase 2)
|
||||
|
||||
The `droneMonitorIndex` is maintained independently of chat sessions — it enables the DroneManager and DroneInspector to receive live log and status events from a drone without requiring a session lock. Cleanup happens automatically on socket disconnect.
|
||||
|
||||
All indexes are kept in sync during connection and disconnection.
|
||||
|
||||
@ -278,19 +197,13 @@ All indexes are kept in sync during connection and disconnection.
|
||||
## 7. Extending the Protocol
|
||||
|
||||
To add a new message:
|
||||
1. Add the message type to `packages/api/src/messages/ide.ts`, `drone.ts`, `web.ts`, or **create a new file** for a related group of types (e.g. `subprocess.ts`).
|
||||
1. Add the message type to `packages/api/src/messages/ide.ts`, `drone.ts`, or `web.ts`.
|
||||
2. Register it in `ClientToServerEvents` or `ServerToClientEvents` in `packages/api/src/messages/socket.ts`.
|
||||
3. Re-export from `packages/api/src/index.ts`.
|
||||
4. Implement the sender (emit) in the Client (`ide` or `drone`) or Server (`CodeSession`/`DroneSession`).
|
||||
5. Implement the handler in the corresponding class or frontend component.
|
||||
6. Implement the forward-path routing if needed.
|
||||
|
||||
### Patterns
|
||||
|
||||
**Request/Response (callback chain):** For "ask and answer" operations (file reads, process stats), use Socket.IO's built-in acknowledgement callback. The client emits with a callback function; the server receives it and calls the callback with the response. The backend proxies both directions.
|
||||
|
||||
**Subscribe/Broadcast:** For streaming events (logs, status), use a subscribe/unsubscribe pattern. The IDE registers interest in a drone's events via `subscribeDrone`. The backend maintains a monitor index and broadcasts events to all subscribers as they arrive from the drone. No polling needed for logs.
|
||||
|
||||
---
|
||||
|
||||
## 8. Reconnection & Message Queuing
|
||||
|
||||
@ -1,174 +0,0 @@
|
||||
# Subagent Context Management
|
||||
|
||||
**Last Updated**: 2026-05-17
|
||||
**Status**: Fix deployed and tested
|
||||
|
||||
## The Core Principle
|
||||
|
||||
When the Master Agent spawns a subagent, the subagent's internal work (thinking, tool calls, stats) must **never** enter the Master Agent's context window. Only the subagent's final response text is relevant to the Master Agent. All other data is preserved in the database for UI display and auditing, but excluded from the context sent to the AI model on subsequent API calls.
|
||||
|
||||
This is the fundamental design contract of subagents: they absorb noise so the Master Agent doesn't have to.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Subagent executes (agent.ts spawnSubagent, line 561)
|
||||
│
|
||||
│ Returns: JSON.stringify({ success: true, data: { prompt, thinking, response, toolCalls, stats } })
|
||||
│
|
||||
▼
|
||||
Master Agent receives result (agent.ts process(), line 280)
|
||||
│
|
||||
├─► Socket emission to frontend (line 295): agent:complete event
|
||||
│ • response: extracted response text (clean)
|
||||
│ • subagent: full data object (for UI/auditing)
|
||||
│
|
||||
├─► Database storage (drone-session.ts onToolCall, line 256):
|
||||
│ • toolCall.response = response text (from agent:complete event)
|
||||
│ • toolCall.subagent = full data object (from agent:complete event)
|
||||
│
|
||||
└─► Master Agent context (agent.ts, line 306-323):
|
||||
• messages.push({ role: "tool", content: responseText })
|
||||
Only the subagent's final answer. NOT the full JSON.
|
||||
```
|
||||
|
||||
### Database Storage (Unchanged — Full Data Preserved)
|
||||
|
||||
The `agent:complete` socket event carries both the clean response text and the full subagent data. The `DroneSession.onAgentComplete` handler in `gadget-code/src/lib/drone-session.ts` (line 515-533) stores both:
|
||||
|
||||
- `toolCall.response` — the subagent's response text
|
||||
- `toolCall.subagent` — the full `IChatSubagentProcess` object (thinking, toolCalls, stats)
|
||||
|
||||
This gives the UI and auditing layer complete visibility into subagent execution while keeping the Master Agent's context clean.
|
||||
|
||||
## The Two Context Paths
|
||||
|
||||
The Master Agent's context is built in two scenarios, and both must apply the subagent filter:
|
||||
|
||||
### 1. Live Context (During a Turn)
|
||||
|
||||
**File**: `gadget-drone/src/services/agent.ts`, lines 306-323
|
||||
|
||||
When the Master Agent's agentic loop processes tool call results, subagent results are parsed and only `data.response` is added to the messages array:
|
||||
|
||||
```typescript
|
||||
let toolResponseContent = result;
|
||||
if (toolCall.function.name === "subagent") {
|
||||
try {
|
||||
const parsed = JSON.parse(result);
|
||||
if (parsed.success && parsed.data?.response) {
|
||||
toolResponseContent = parsed.data.response;
|
||||
}
|
||||
} catch (parseError) {
|
||||
this.log.warn("failed to parse subagent result for context", {
|
||||
error: parseError instanceof Error ? parseError.message : String(parseError),
|
||||
resultLength: result.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
createdAt: turn.createdAt,
|
||||
role: "tool",
|
||||
content: toolResponseContent,
|
||||
toolCallId: toolCall.callId,
|
||||
toolName: toolCall.function.name,
|
||||
});
|
||||
```
|
||||
|
||||
If JSON parsing fails (which should not happen in normal operation), the system falls back to the raw result and logs a warning. It does not crash.
|
||||
|
||||
### 2. Historical Context (From Previous Turns)
|
||||
|
||||
**File**: `gadget-drone/src/services/agent.ts`, `buildSessionContext()`, lines 435-454
|
||||
|
||||
When rebuilding context from database records of previous turns, the already-parsed `toolCall.subagent.response` field is used instead of `toolCall.response`:
|
||||
|
||||
```typescript
|
||||
let toolResponseContent = toolCall.response;
|
||||
if (toolCall.name === "subagent" && toolCall.subagent?.response) {
|
||||
toolResponseContent = toolCall.subagent.response;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
createdAt: turn.createdAt,
|
||||
role: "tool",
|
||||
content: toolResponseContent,
|
||||
toolCallId: toolCall.callId,
|
||||
toolName: toolCall.name,
|
||||
});
|
||||
```
|
||||
|
||||
For historical context, no JSON parsing is needed — the data was parsed and stored as a structured `IChatSubagentProcess` when the `agent:complete` event was received. The optional chaining `toolCall.subagent?.response` handles older records where the `subagent` field may not exist (backward compatibility).
|
||||
|
||||
## What Is NOT Changed
|
||||
|
||||
- **Database storage**: Still stores full subagent data (`toolCall.subagent` with thinking, toolCalls, stats)
|
||||
- **Socket emissions**: `agent:complete` event still carries full data to the frontend
|
||||
- **Non-subagent tools**: `file_read`, `grep`, etc. still have their full responses in context
|
||||
- **Token accounting**: Still uses `result.length` for token estimates (the full result was still generated)
|
||||
- **Pruning**: `pruneSessionContext()` remains a TODO stub (out of scope for this fix)
|
||||
|
||||
## Types
|
||||
|
||||
The type definitions supporting this system live in `packages/api/src/interfaces/chat-turn.ts`:
|
||||
|
||||
```typescript
|
||||
interface IChatToolCall {
|
||||
callId: string;
|
||||
name: string;
|
||||
parameters: string;
|
||||
response: string; // response text (clean for subagents)
|
||||
subagent?: IChatSubagentProcess; // full data, only for subagent calls
|
||||
}
|
||||
|
||||
interface IChatSubagentProcess {
|
||||
prompt: string;
|
||||
thinking?: string;
|
||||
response: string;
|
||||
toolCalls: IChatToolCall[];
|
||||
stats: IChatTurnStats;
|
||||
}
|
||||
```
|
||||
|
||||
The `subagent` field is optional (`?`) because it only exists on subagent tool call records. Regular tool calls (file_read, grep, etc.) have `response` but no `subagent`.
|
||||
|
||||
## Tests
|
||||
|
||||
Tests are in `gadget-drone/src/services/agent.test.ts`:
|
||||
|
||||
- **"replays historical tool results as role:tool messages with unaltered content"** — verifies non-subagent tool calls still pass through unchanged
|
||||
- **"uses subagent.response instead of full JSON for subagent tool calls in context replay"** — verifies `buildSessionContext` extracts only the response text from `toolCall.subagent.response`, and explicitly asserts that JSON metadata (`"success":true`, `"toolCalls"`, `"thinking"`) is NOT present
|
||||
- **"falls back to toolCall.response for subagent calls without parsed subagent data"** — verifies backward compatibility when a subagent tool call record exists in the DB but has no `subagent` field (pre-fix data)
|
||||
|
||||
## Protocol Correctness
|
||||
|
||||
The message ordering protocol is intact:
|
||||
|
||||
1. `role: "user"` — the user's prompt
|
||||
2. `role: "assistant"` — the assistant's response, with `toolCalls` array if tools were called
|
||||
3. `role: "tool"` — tool response, with `toolCallId` matching the call's `id`
|
||||
|
||||
This ordering is maintained in both the live context (agent.ts process()) and historical replay (buildSessionContext). Tool call IDs are preserved correctly.
|
||||
|
||||
The fix does not alter message ordering, IDs, or roles. It only changes the **content** of `role: "tool"` messages when the tool name is `"subagent"`.
|
||||
|
||||
## Relationship to HTTP 400 Errors
|
||||
|
||||
Before this fix, the full JSON string from subagent results was being placed into `role: "tool"` message content. This JSON contained nested quotes, brackets, and large payloads that some AI API providers may have rejected or misinterpreted, causing intermittent HTTP 400 errors. By extracting only the response text (a plain string), the tool response content is now clean and consistent with what the API expects.
|
||||
|
||||
## Future Work
|
||||
|
||||
- **Pruning**: `pruneSessionContext()` (agent.ts line 460) is a stub that could be implemented to cull old or redundant tool responses from the context window. This is a separate concern from the subagent context fix.
|
||||
- **Context window monitoring**: Consider adding debug logging that reports context window size and subagent response size reductions for observability.
|
||||
|
||||
## References
|
||||
|
||||
- Original design document: `docs/subagents.md`
|
||||
- Agent implementation: `gadget-drone/src/services/agent.ts`
|
||||
- Agent tests: `gadget-drone/src/services/agent.test.ts`
|
||||
- Database storage handler: `gadget-code/src/lib/drone-session.ts` (onAgentComplete, line 515)
|
||||
- Chat Turn types: `packages/api/src/interfaces/chat-turn.ts`
|
||||
- Subagent tool definition: `packages/ai-toolbox/src/chat/subagent.ts`
|
||||
@ -1,117 +0,0 @@
|
||||
# Gadget
|
||||
|
||||
An AI-fueled browser extension and web application for the next generation of social media.
|
||||
|
||||
## Overview
|
||||
|
||||
Gadget is a browser extension - and web application - that implements a chat with an AI agent named Gadget.
|
||||
|
||||
Gadget, by calling tools, will help the user use the web. Gadget will be able to navigate to URls either in a new tab or the current one. Gadget will be able to read the contents of a web page or snap a screenshot to view an image, etc., to help summarize the contents, help the user understand the contents, find related content, etc. Gadget will, on some sites, be able to fill out forms for the User (if they implement the Gadget interface), and assist the user with basic online tasks and automations.
|
||||
|
||||
This project is only at the proof-of-concept stage, and we are just getting started now. A backend microservice will be created built in TypeScript on NodeJS using ExpressJS, Socket.io, MongoDB, Qdrant (vector database) and the [ollama](https://www.npmjs.com/package/ollama) API. The front-end will POST chat messages from the sidebar browser extension, and receive streaming responses for the display to the user in the scrolling message list in the sidebar extension.
|
||||
|
||||
We are only concerned with supporting the Google Chromium browser, but we will be using the [Vite](https://vitejs.dev/) build tool for both projects to make them as lightweight and performant as possible. We will also use ReactJS for the front-end projects, and TailwindCSS for styling, sharing as much as possible in ./src/lib when appropriate.
|
||||
|
||||
## Sidebar Chat
|
||||
|
||||
The design of the sidebar chat component is deceptively basic at first, and by default. Complexity lives in slide-out panels. All the user sees normally is a header bar, scrolling chat message list (with styled scrollbar), and their input bar at the bottom.
|
||||
|
||||
The header of the sidebar is 24px in height, and will present the Gadget icon (opens gadget.gab.com in a named "\_gadget" tab). The Gadget icon is followed a basic menu of simple selections that will be square icons.
|
||||
|
||||
To interact with Gadget, people generally don't use a "menu" or click-style actions. They enter a chat message, ask questions, or just tell Gadget the settings they want. "Gadget, please change to dark mode," will do that. The chat message is sent to the backend, where it is passed to the Ollama `chat` API for processing and for Tool calls per the Ollama API standard. Socket.io is used to stream the response back to the sidebar widget for display in real-time. As thinking messages arrive, their content is added to the "Thinking..." region of the current turn. As response messages arrive, they are added to the Response area of the current turn.
|
||||
|
||||
What the user can expect Gadget to be able to do for them depends on whether they are accessing the mobile or Desktop version. The mobile version is NOT a browser extension. It is just a web page, installable as an app, that the User can open to FETCH information and have conversations with Gadget. Gadget will not, however, be able to control their browser and provide browser-based automations when being accessed from the mobile interface.
|
||||
|
||||
## Turns
|
||||
|
||||
A turn is a "block" in the scrolling chat message list, and will contain the following information:
|
||||
|
||||
- User's prompt (chat input text)
|
||||
- LLM's thinking messages in a Thinking block (expandable/collapsable, default collapsed)
|
||||
- LLM's response messages in a Response block
|
||||
- Tool calls in a Tools block (collapsed by default, opens the Tool Shelf slide-out panel when selected)
|
||||
|
||||
Turns are the history of the chat. They are stored on the server. `/api/v1/chat/:sessionId` is their endpoint. The User will POST chat input to `/api/v1/chat/:sessionId` to send a chat message, and the response will be streamed to them over Socket.io to a channel named for the chat session ID that the User joins when connecting to that chat session.
|
||||
|
||||
## Sessions
|
||||
|
||||
The first icon after the Gadget icon in the header bar is the Sessions icon. This will toggle open the Session panel, where the User can scroll through their sessions with Gadget, and search them. These are all endpoints at and under `/api/v1/chat`.
|
||||
|
||||
Justified right in the header is a + plus icon, which creates a new session and immediate connects to it by POSTing to `/api/v1/chat` specifying session type of `mobile` (app), `desktop` (app), or `extension` (browser extension), to indicate which experience and set of capabilities is to be delivered to the User.
|
||||
|
||||
This will control the selection of separate system prompts, which are defined in `./data/prompts/gadget/system.desktop.md` and `./data/prompts/gadget/system.mobile.md` and `./data/prompts/gadget/system.extension.md`. A starter system prompt is provided in each of those files to get the project started. They are already being loaded in the AgentService when building the Agent's system prompt, merging with runtime information, etc.
|
||||
|
||||
The `Session` object (Mongoose Model, etc.) **is** the session context. The `ChatMessage` model will have a `session` field that references the `Session` by `Session._id`.
|
||||
|
||||
## Pinboard
|
||||
|
||||
The Pinboard is a persistent note-taking mechanism within a chat session. It allows the LLM agent (Gadget) to store and remove notes directly in the session context/system prompt, giving the model a way to edit its own context and maintain important information across turns without relying on conversation history.
|
||||
|
||||
### How It Works
|
||||
|
||||
- Pins are stored as a `pins` array on the `ChatSession` model, each with an auto-generated `_id` and `content` string
|
||||
- When building the system prompt for each chat turn, all pins are appended under a `## PINBOARD` section
|
||||
- The agent uses two tools to manage pins:
|
||||
- `pin_add`: Adds a new pin with text content
|
||||
- `pin_remove`: Removes a pin by its `_id`
|
||||
- Total pinboard content is limited to 8192 characters across all pins. When the limit is reached, the agent must remove existing pins before adding new ones
|
||||
|
||||
### Why It Matters
|
||||
|
||||
The pinboard gives the agent agency over its own context. Instead of the agent having to re-read conversation history or repeat important information in every response, it can pin key facts — user preferences, task specifications, research findings, URLs — and have them automatically included in the system prompt for every subsequent turn. This is more efficient than writing notes in response text, keeps responses clean, and ensures critical context is always available.
|
||||
|
||||
### Implementation
|
||||
|
||||
- Model: `IChatSessionPin` subdocument schema with `_id` and `content` fields (`src/models/chat-session.ts`)
|
||||
- Tools: `PinAddTool` and `PinRemoveTool` in `src/tools/storage/`
|
||||
- System prompt: Pins appended in `buildAgentSystemPrompt()` in `src/services/agent.ts`
|
||||
- Prompts: Pinboard section added to all three system prompts (`data/prompts/gadget/system.*.md`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
Generally, the client projects will call into the backend project to receive backend data and information services such as AI inference over HTTP. The backend will usually respond quickly with an asynchronous HTTP 200 indicating, "I got your chat input." It will then create a Bull Queue job, and submit the session (which contains the User) and prompt as job data to the `gadget-chat` job queue.
|
||||
|
||||
A worker process will pick up the job, and perform the AI inference. The response is streamed in real-time back to the User over Socket.io, where it is added to the current turn in the chat message list in the appropriate areas of the current processing turn. Thinking messages go into the Thinking... area. Response messages append to the Response area. Tool calls (their parameters and response, errors included) are added to the Tools slide-out panel.
|
||||
|
||||
### Common
|
||||
|
||||
./src/lib - common code to be shared by all, such as database/redis connections, the Ollama API instance, etc.
|
||||
|
||||
### Backend
|
||||
|
||||
./src/models - the Mongoose TypeScript interface, schema, and model definitions/implementations
|
||||
./src/services - the service layer for the application
|
||||
./src/tools - Ollama API Tool definitions, and their implementations
|
||||
./src/controllers - the ExpressJS controllers that implement the HTTP interface
|
||||
./src/workers - the background workers that perform tasks such as AI inference, etc.
|
||||
|
||||
./src/web-app.ts - the main web app/backend server
|
||||
./src/web-cli.ts - the command-line interface for performing low-level admin functions
|
||||
|
||||
### Front-End (HTML5 PWA/SPA)
|
||||
|
||||
./src/client - the ReactJS/Tailwind/Vite client used to build the browser application (PWA/SPA)
|
||||
./src/extension - the ReactJS/Tailwind/Vite client project used to build the browser sidebar extension
|
||||
|
||||
These are separate projects that let us implement the separate concerns of the Desktop App and the Mobile app with better focus and results. They can share common logic and interfaces through `./src/lib`, but can then implement their own UI/UX components and make full use of the device they are running on.
|
||||
|
||||
#### Backend
|
||||
|
||||
- NodeJS
|
||||
- MongoDB
|
||||
- Redis
|
||||
- Qdrant
|
||||
- TypeScript
|
||||
- ExpressJS
|
||||
- Socket.io
|
||||
- Ollama API
|
||||
- BullMQ
|
||||
- Mongoose
|
||||
- tsx
|
||||
- Vite
|
||||
- Tailwind
|
||||
- ReactJS
|
||||
- Jest 26+
|
||||
- SuperTest 5+
|
||||
- ESLint 8+
|
||||
- Prettier 2+
|
||||
@ -36,12 +36,6 @@ You must remain within the project directory, which is the current working direc
|
||||
|
||||
**DO NOT** spawn a subagent as a workaround for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior, then stop. Let the User help determine what to do next.
|
||||
|
||||
{{process_management_block}}
|
||||
|
||||
## PLAN ACCESS
|
||||
|
||||
You have access to plans stored in Gadget's .gadget directory. Use `plan_list` to discover existing plans, `plan_file_read` to read them, and `plan_file_edit` to update TODO checklists as you complete work items. Always check for an active plan before starting work.
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
You always provide regular updates to explain your thinking and reasoning while working and calling tools. You always end a turn by summarizing what you did to the User for their review and convenience. When the user sends you a prompt:
|
||||
|
||||
@ -32,12 +32,6 @@ Use your tools proactively. When working on the Gadget Code codebase, immediatel
|
||||
|
||||
NOTICE: IF YOU EXPERIENCE DIFFICULTY USING ANY TOOLS OR RECEIVE A RESPONSE THAT IS UNEXPECTED OR SEEMS ERRONEOUS (TOOL MALFUNCTION), PLEASE **IMMEDIATELY** DOCUMENT WHAT THE TOOL DID THAT YOU DIDN'T EXPECT - AND STOP. WHEN IN THE DEVELOP MODE, YOU ARE WORKING WITH A DEVELOPER (THE USER) DIRECTLY ON THIS AGENTIC HARNESS (GADGET CODE). WE MAY NEED TO DEBUG OR DIAGNOSE A PROBLEM WITH A TOOL AS WE WORK.
|
||||
|
||||
{{process_management_block}}
|
||||
|
||||
## PLAN ACCESS
|
||||
|
||||
You have access to plans stored in Gadget's .gadget directory. Use `plan_list` to discover existing plans, `plan_file_read` to read them, and `plan_file_edit` to update TODO checklists as you complete work items.
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
Work in a loop through the User's request for this turn, resolving the work items that need done until finished, explaining your thinking and reasoning while calling tools and doing your work.
|
||||
|
||||
@ -40,23 +40,6 @@ Don't announce tool usage, just execute and use findings to inform your planning
|
||||
|
||||
{{tool_block}}
|
||||
|
||||
## PLAN TOOLS
|
||||
|
||||
You have four tools for persisting plans and knowledge in Gadget's .gadget directory:
|
||||
|
||||
- **plan_list** — List contents of the .gadget directory (or a subdirectory). Use it to discover existing plans before creating new ones.
|
||||
- **plan_file_read** — Read a file from .gadget with line numbers. All paths are relative to the .gadget directory.
|
||||
- **plan_file_write** — Create or overwrite a file in .gadget. Use for new plans, TODOs, design documents.
|
||||
- **plan_file_edit** — Search-and-replace edit on a .gadget file. Use for updating existing plans and TODO checklists.
|
||||
|
||||
### Conventions
|
||||
- Store plans in `.gadget/plans/` (e.g., `plans/feature-name.md`)
|
||||
- Store TODOs alongside their plans (e.g., `plans/feature-name-todo.md`)
|
||||
- Paths passed to these tools are **relative to .gadget/**, not the project root
|
||||
- These tools operate ONLY in .gadget/ — they cannot read or write project source files
|
||||
|
||||
{{process_management_block}}
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
When the user sends you a prompt:
|
||||
|
||||
@ -22,12 +22,6 @@ Use your tools decisively. Run tests, check builds, verify deployments, and exec
|
||||
|
||||
{{tool_block}}
|
||||
|
||||
{{process_management_block}}
|
||||
|
||||
## PLAN ACCESS
|
||||
|
||||
You have access to plans stored in Gadget's .gadget directory. Use `plan_list` and `plan_file_read` to find and read active plans. Update TODO checklists with `plan_file_edit` as shipping items are verified.
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
When the user sends you a prompt:
|
||||
|
||||
@ -22,12 +22,6 @@ Use your tools aggressively for testing. Run tests frequently, read test files,
|
||||
|
||||
{{tool_block}}
|
||||
|
||||
{{process_management_block}}
|
||||
|
||||
## PLAN ACCESS
|
||||
|
||||
You have access to plans stored in Gadget's .gadget directory. Use `plan_list` and `plan_file_read` to find and read active plans. Update TODO checklists with `plan_file_edit` as you verify test items.
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
When the user sends you a prompt:
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
## PROCESS MANAGEMENT
|
||||
|
||||
The `subprocess` tool is for managing subprocesses related to the current project. Your project may produce servers and other background processes that should be started, stopped, and managed differently than what `shell_cmd` is designed for.
|
||||
|
||||
You can create, list, and kill child processes using the `subprocess` tool. You can also read their `stdout` and `stderr` logs, which are being written to disk (and managed) as a convenience. When you stop a process, it's log file is closed and removed.
|
||||
|
||||
If you want to stop a process _without_ removing the log, you use the `halt` subprocess command (cmd=halt, pid=###). You can then examine the logs. When you are done examining the logs, you use [cmd=kill, pid=###] to remove the process entry and logs.
|
||||
@ -17,8 +17,6 @@ Always be specific when asking a subagent to do work. When constructing your pro
|
||||
5. Define "done" for the agent so it knows when to stop working
|
||||
6. Tell the agent exactly how to report back to you when done
|
||||
|
||||
Subagents have access to the same file, network, and system tools as the main agent. They do **not** have access to `plan_*` tools or the `subagent` tool — those are reserved for the main agent only.
|
||||
|
||||
### SUBAGENT: EXPLORE
|
||||
|
||||
The Explore subagent is tuned for knowing how to dig through a project directory, find the requested information, and generate a report back to you containing the information you need (if available).
|
||||
|
||||
@ -140,169 +140,6 @@ Components:
|
||||
|
||||
Implementation: `frontend/src/pages/Home.tsx` - DashboardSidebar component
|
||||
|
||||
### Drone Inspector (Dashboard Inline)
|
||||
|
||||
When a user clicks a drone in the sidebar's Drones list, the main content area switches to the **Drone Inspector**:
|
||||
|
||||
```
|
||||
+-----------------------------------------------------+
|
||||
| Drone Inspector ← Back to |
|
||||
| Dashboard |
|
||||
+-----------------------------------------------------+
|
||||
| +-------------------------------------------------+ |
|
||||
| | Hostname | |
|
||||
| | drone-alpha (mono) | |
|
||||
| +-------------------------------------------------+ |
|
||||
| | Workspace | |
|
||||
| | /path/to/workspace (mono) | |
|
||||
| +-------------------------------------------------+ |
|
||||
| | Status | |
|
||||
| | ● available (or ● busy, ● offline) | |
|
||||
| +-------------------------------------------------+ |
|
||||
| | Registered | |
|
||||
| | 5/14/2026, 10:00:00 AM | |
|
||||
| +-------------------------------------------------+ |
|
||||
+-----------------------------------------------------+
|
||||
```
|
||||
|
||||
Implementation: `frontend/src/pages/Home.tsx` - DroneInspector component (lines 36-93)
|
||||
|
||||
The Drone Inspector is a simple read-only card view. For full drone operations (terminate, logs, monitoring), users navigate to the Drone Manager at `/drones` via the gear icon in the Drones sidebar header.
|
||||
|
||||
## Drone Manager View
|
||||
|
||||
Route: `/drones`
|
||||
|
||||
The Drone Manager is a full-page view for detailed drone inspection and operations. It replaces the main content area entirely.
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
+----------------------------------+----------------------------------------+
|
||||
| Drone Manager | Drone Details [Terminate] |
|
||||
+----------------------------------+----------------------------------------+
|
||||
| Online Drones (N) | +-----------+ +-----------+ |
|
||||
| | | Hostname | | Status | |
|
||||
| +----------------------------+ | | drone-1 | | ● busy | |
|
||||
| | ● drone-1 available | | +-----------+ +-----------+ |
|
||||
| | /path/to/workspace | | +-----------+ +-----------+ |
|
||||
| +----------------------------+ | | Workspace | | Registered| |
|
||||
| +----------------------------+ | | /path/... | | 5/14/2026 | |
|
||||
| | ● drone-2 busy | | +-----------+ +-----------+ |
|
||||
| | /path/to/workspace | | |
|
||||
| +----------------------------+ | Drone Monitor |
|
||||
| | +--------------------------------------+ |
|
||||
| Offline Drones (N) | | Monitor charts coming soon. | |
|
||||
| | | Memory usage, AI operations, and log | |
|
||||
| +----------------------------+ | | production metrics will be displayed | |
|
||||
| | ○ drone-3 offline | | | here. | |
|
||||
| | /path/to/workspace | | +--------------------------------------+ |
|
||||
| +----------------------------+ | |
|
||||
| | Drone Log (Live) |
|
||||
| | +--------------------------------------+ |
|
||||
| | | [10:00:01] [PLACEHOLDER] Log entry 1 | |
|
||||
| | | [10:00:02] [PLACEHOLDER] Log entry 2 | |
|
||||
| | | [10:00:03] [PLACEHOLDER] Log entry 3 | |
|
||||
| | | ... | |
|
||||
| | +--------------------------------------+ |
|
||||
+----------------------------------+----------------------------------------+
|
||||
```
|
||||
|
||||
Current features (Phase 1):
|
||||
|
||||
- **Drone list** (left sidebar): Split into Online and Offline sections. Each list item shows status dot, hostname, and workspace path. Click to inspect.
|
||||
- **Drone details** (right panel, top): 2x2 grid showing hostname, status with dot, workspace, registration date. Terminate button (red) for non-offline drones.
|
||||
- **Drone Monitor** (right panel, middle): Placeholder for future monitoring charts.
|
||||
- **Drone Log** (right panel, bottom): Collapsible panel with auto-scrolling log viewer. Log entries show timestamp and message. Auto-scroll pauses when user scrolls up, resumes when scrolled to bottom. Max 200 entries shown. Placeholder entries currently used, awaiting live log streaming.
|
||||
|
||||
Implementation: `frontend/src/pages/DroneManager.tsx`
|
||||
|
||||
### Phase 2: SubProcess Monitor (Complete)
|
||||
|
||||
Phase 2 was implemented on the `feature/process-management` branch. The Drone Manager and Drone Inspector now show live SubProcess data and drone logs via Socket.IO.
|
||||
|
||||
#### Socket Protocol
|
||||
|
||||
Three new socket events added (defined in `packages/api/src/messages/subprocess.ts`):
|
||||
|
||||
| Event | Direction | Purpose | Mechanism |
|
||||
|-------|-----------|---------|-----------|
|
||||
| `requestProcessStats` | IDE → Backend → Drone | Request typed subprocess list | Callback-chain (like `fileTreeRequest`) |
|
||||
| `subscribeDrone` | IDE → Backend | Subscribe to live drone events (log, status) | Registers socket in monitor index |
|
||||
| `unsubscribeDrone` | IDE → Backend | Unsubscribe from live drone events | Removes socket from monitor index |
|
||||
|
||||
Broadcast events (backend → IDE, no request needed):
|
||||
|
||||
| Event | Payload | When |
|
||||
|-------|---------|------|
|
||||
| `drone:log` | `{ timestamp, level, component, message, metadata? }` | On every drone `log` emission |
|
||||
| `drone:status` | `{ timestamp, message }` | On every drone `status` emission |
|
||||
|
||||
#### SubProcess Table
|
||||
|
||||
Rendered by `frontend/src/components/SubProcessTable.tsx`. Features:
|
||||
- Monospace table: PID, CMD, PROJECT, STATUS, UPDATED
|
||||
- Status dots: green for running, yellow for halted, red for error
|
||||
- Clickable rows with selection highlight
|
||||
- Header shows running count: "SubProcess Monitor (3 running)"
|
||||
- Empty state: "No managed subprocesses. The agent has not spawned any processes for this session."
|
||||
|
||||
#### Resource Gauges (Canvas)
|
||||
|
||||
Rendered by `frontend/src/components/DroneMonitor.tsx` + `DroneMonitorGauge.tsx`:
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ CPU │ │ NETWORK │ │ FILE I/O │
|
||||
│ ▁▂▃▄▅▆▇█▇▆ │ │ ▁▂▃▄▅▆▇█▇▆ │ │ ▁▂▃▄▅▆▇█▇▆ │
|
||||
│ 42% │ │ 27% │ │ 15% │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
- Canvas-based waveform with glow effect (studio/scientific equipment aesthetic)
|
||||
- Brand red for CPU, cyan for NETWORK, green for FILE I/O
|
||||
- Grid lines, numeric readout, gradient fill under trace
|
||||
- Data is currently simulated (random walk) — placeholder for real metrics
|
||||
|
||||
#### Live Log Streaming
|
||||
|
||||
The Drone Log viewer now receives real log events via socket instead of placeholders:
|
||||
- Log events flow: `drone` → `DroneSession.onLog()` → broadcast to monitor sessions via `drone:log` event
|
||||
- Uses the same `LogRenderer` component as the ChatSession `LogPanel` (refactored in Phase 2)
|
||||
- Auto-scroll with pause-on-scroll-up behavior preserved
|
||||
- Max 200 entries maintained
|
||||
|
||||
#### DroneInspector (Dashboard)
|
||||
|
||||
The inline Drone Inspector in the Home page (`frontend/src/pages/Home.tsx`):
|
||||
- Shows SubProcess count summary ("3 running" or "No managed processes")
|
||||
- Contains a compact `LogRenderer` (last 50 entries, max-h-48)
|
||||
- "Open in Drone Manager →" link navigates to `/drones` with route state for auto-selection
|
||||
|
||||
#### Polling Behavior
|
||||
|
||||
- `requestProcessStats` polled every **1 second** while a drone is selected (both DroneManager and DroneInspector)
|
||||
- Subscribe/unsubscribe lifecycle tied to component mount/unmount
|
||||
- All timers and socket listeners cleaned up on unmount or drone deselection
|
||||
|
||||
#### Supporting Components
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| `SubProcessTable` | `frontend/src/components/SubProcessTable.tsx` | Process list table |
|
||||
| `DroneMonitor` | `frontend/src/components/DroneMonitor.tsx` | 3-gauge resource monitor container |
|
||||
| `DroneMonitorGauge` | `frontend/src/components/DroneMonitorGauge.tsx` | Canvas waveform gauge |
|
||||
| `LogRenderer` | `frontend/src/components/LogRenderer.tsx` | Reusable log rendering core |
|
||||
|
||||
#### Backend Routing
|
||||
|
||||
- `SocketService.addDroneMonitor()` / `removeDroneMonitor()` — manages monitor index (`Map<registrationId, Set<socketId>>`)
|
||||
- `SocketService.broadcastToMonitors()` — broadcasts events to all monitoring sockets
|
||||
- `SocketService.getDroneSessionByRegistrationId()` — lookup for non-chat-session drone routing
|
||||
- `DroneSession.onLog()` / `onStatus()` — extended to broadcast to monitors after chat-session routing
|
||||
- `CodeSession.onRequestProcessStats()` — proxies to drone with callback
|
||||
- `CodeSession.onSubscribeDrone()` / `onUnsubscribeDrone()` — registers/unregisters monitor
|
||||
|
||||
## Project Manager View
|
||||
|
||||
The Project Manager presents:
|
||||
|
||||
@ -30,7 +30,6 @@
|
||||
"@replit/codemirror-lang-csharp": "^6.2.0",
|
||||
"@uiw/codemirror-theme-tomorrow-night-blue": "^4.25.9",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
"lucide-react": "^1.16.0",
|
||||
"marked": "^16.4.2",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { memo, useState, useCallback } from "react";
|
||||
import { marked } from "marked";
|
||||
import type { ChatTurn as ChatTurnType, ChatTurnBlockTool } from "../lib/api";
|
||||
import ToolCallDisplay from "./ToolCallDisplay";
|
||||
|
||||
interface ChatTurnProps {
|
||||
turn: ChatTurnType;
|
||||
@ -152,13 +151,16 @@ const ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) {
|
||||
const subagent = toolCall.subagent;
|
||||
return (
|
||||
<div key={idx} className="tool-call">
|
||||
<ToolCallDisplay
|
||||
callId={toolCall.callId}
|
||||
name={toolCall.name}
|
||||
parameters={toolCall.parameters}
|
||||
response={toolCall.response}
|
||||
createdAt={block.createdAt}
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-xs font-mono text-text-secondary">
|
||||
<span className="text-brand">●</span>
|
||||
<span>{toolCall.name}</span>
|
||||
{toolCall.response && !subagent && (
|
||||
<span className="text-green-500">✓</span>
|
||||
)}
|
||||
{subagent && subagent.stats && (
|
||||
<span className="text-green-500">✓</span>
|
||||
)}
|
||||
</div>
|
||||
{subagent && <SubagentDisplay subagent={subagent} />}
|
||||
</div>
|
||||
);
|
||||
@ -236,14 +238,14 @@ function SubagentDisplay({
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{subagent.toolCalls.map((tc, i) => (
|
||||
<ToolCallDisplay
|
||||
<div
|
||||
key={tc.callId || i}
|
||||
callId={tc.callId}
|
||||
name={tc.name}
|
||||
parameters={tc.parameters}
|
||||
response={tc.response}
|
||||
defaultExpanded={false}
|
||||
/>
|
||||
className="flex items-center gap-2 text-xs font-mono text-text-secondary"
|
||||
>
|
||||
<span className="text-yellow-500">◆</span>
|
||||
<span>{tc.name}</span>
|
||||
{tc.response && <span className="text-green-500">✓</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import DroneMonitorGauge from './DroneMonitorGauge';
|
||||
|
||||
interface DroneMonitorProps {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function generateWave(base: number, variance: number, length: number): number[] {
|
||||
const wave: number[] = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
wave.push(base + Math.sin(i * 0.1) * variance + (Math.random() - 0.5) * variance * 0.5);
|
||||
}
|
||||
return wave;
|
||||
}
|
||||
|
||||
export default function DroneMonitor({ visible }: DroneMonitorProps) {
|
||||
const [cpuData, setCpuData] = useState<number[]>(() => generateWave(35, 20, 60));
|
||||
const [netData, setNetData] = useState<number[]>(() => generateWave(20, 15, 60));
|
||||
const [ioData, setIoData] = useState<number[]>(() => generateWave(10, 10, 60));
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCpuData((prev) => {
|
||||
const next = [...prev.slice(1), Math.max(0, Math.min(100, prev[prev.length - 1]! + (Math.random() - 0.5) * 10))];
|
||||
return next;
|
||||
});
|
||||
setNetData((prev) => {
|
||||
const next = [...prev.slice(1), Math.max(0, Math.min(100, prev[prev.length - 1]! + (Math.random() - 0.5) * 8))];
|
||||
return next;
|
||||
});
|
||||
setIoData((prev) => {
|
||||
const next = [...prev.slice(1), Math.max(0, Math.min(100, prev[prev.length - 1]! + (Math.random() - 0.5) * 6))];
|
||||
return next;
|
||||
});
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<div className="bg-bg-secondary border border-border-default rounded overflow-hidden">
|
||||
<div className="p-2 border-b border-border-subtle bg-bg-tertiary">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Resource Monitor
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex gap-1 p-1 justify-center">
|
||||
<DroneMonitorGauge
|
||||
label="CPU"
|
||||
data={cpuData}
|
||||
unit="%"
|
||||
color="#c20600"
|
||||
/>
|
||||
<DroneMonitorGauge
|
||||
label="NETWORK"
|
||||
data={netData}
|
||||
unit="%"
|
||||
color="#06b6d4"
|
||||
/>
|
||||
<DroneMonitorGauge
|
||||
label="FILE I/O"
|
||||
data={ioData}
|
||||
unit="%"
|
||||
color="#22c55e"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,116 +0,0 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
interface DroneMonitorGaugeProps {
|
||||
label: string;
|
||||
data: number[];
|
||||
unit: string;
|
||||
color: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export default function DroneMonitorGauge({
|
||||
label,
|
||||
data,
|
||||
unit,
|
||||
color,
|
||||
width = 320,
|
||||
height = 160,
|
||||
}: DroneMonitorGaugeProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#1a1a1a';
|
||||
ctx.lineWidth = 1;
|
||||
for (let y = 0; y < 4; y++) {
|
||||
const yy = (height / 4) * y;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, yy);
|
||||
ctx.lineTo(width, yy);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Border frame — sharp edge like studio equipment
|
||||
ctx.strokeStyle = '#2a2a2a';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
|
||||
|
||||
if (data.length < 2) return;
|
||||
|
||||
// Plot the waveform
|
||||
const padX = 8;
|
||||
const padY = 8;
|
||||
const plotW = width - padX * 2;
|
||||
const plotH = height - padY * 2;
|
||||
const maxVal = 100;
|
||||
|
||||
// Gradient fill under the trace
|
||||
const gradient = ctx.createLinearGradient(0, padY, 0, padY + plotH);
|
||||
gradient.addColorStop(0, color + '40');
|
||||
gradient.addColorStop(1, color + '05');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padX, padY + plotH);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padX + (i / (data.length - 1)) * plotW;
|
||||
const y = padY + plotH - (Math.min(Math.max(data[i] ?? 0, 0), maxVal) / maxVal) * plotH;
|
||||
if (i === 0) ctx.lineTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(padX + plotW, padY + plotH);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Trace line with glow
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padX + (i / (data.length - 1)) * plotW;
|
||||
const y = padY + plotH - (Math.min(Math.max(data[i] ?? 0, 0), maxVal) / maxVal) * plotH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Current value readout
|
||||
const latest = data[data.length - 1] ?? 0;
|
||||
ctx.fillStyle = '#d4d4d4';
|
||||
ctx.font = 'bold 24px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`${Math.round(latest)}${unit}`, width / 2, height - 12);
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#737373';
|
||||
ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(label, width / 2, 16);
|
||||
}, [data, label, unit, color, width, height]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width, height }}
|
||||
className="block"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,173 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface ExpandedPromptEditorProps {
|
||||
promptInput: string;
|
||||
onPromptChange: (value: string) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onClose: () => void;
|
||||
onClearPrompt: () => void;
|
||||
disabled: boolean;
|
||||
placeholder: string;
|
||||
isProcessing: boolean;
|
||||
isAborting: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ExpandedPromptEditor({
|
||||
promptInput,
|
||||
onPromptChange,
|
||||
onSubmit,
|
||||
onClose,
|
||||
onClearPrompt,
|
||||
disabled,
|
||||
placeholder,
|
||||
isProcessing,
|
||||
isAborting,
|
||||
onCancel,
|
||||
}: ExpandedPromptEditorProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-focus the textarea on mount
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Escape closes the editor
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Enter or Cmd+Enter submits the prompt
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
if (!disabled && promptInput.trim()) {
|
||||
onSubmit(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular Enter inserts a newline (default textarea behavior)
|
||||
// No special handling needed — this is the key difference from the simple prompt bar
|
||||
};
|
||||
|
||||
const handleSubmitClick = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!disabled && promptInput.trim()) {
|
||||
onSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Sidebar — Tools */}
|
||||
<div className="w-48 border-r border-border-subtle bg-bg-secondary flex flex-col shrink-0">
|
||||
{/* Sidebar Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary border-b border-border-subtle shrink-0">
|
||||
<span className="text-xs font-medium text-text-secondary uppercase tracking-wider">Tools</span>
|
||||
</div>
|
||||
{/* Sidebar Content — Placeholder for future tools */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div className="text-text-muted text-xs space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<svg className="w-3.5 h-3.5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
<span className="text-text-secondary font-medium">Snippets</span>
|
||||
</div>
|
||||
<p className="pl-5.5 text-text-muted text-[11px] leading-relaxed">
|
||||
Quick text snippets for common prompt patterns
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<svg className="w-3.5 h-3.5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<span className="text-text-secondary font-medium">Prompt Library</span>
|
||||
</div>
|
||||
<p className="pl-5.5 text-text-muted text-[11px] leading-relaxed">
|
||||
Saved and shared prompt templates
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border-subtle">
|
||||
<p className="text-text-muted text-[11px] leading-relaxed">
|
||||
More tools coming soon...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Editor Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary border-b border-border-subtle shrink-0">
|
||||
<span className="text-sm font-medium text-text-secondary">Prompt Editor</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearPrompt}
|
||||
disabled={!promptInput}
|
||||
className="px-4 py-1.5 text-text-secondary hover:text-text-primary hover:bg-bg-secondary rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
title="Clear prompt text"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{isProcessing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isAborting}
|
||||
className="px-4 py-1.5 bg-red-600 text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
{isAborting ? 'Aborting...' : 'Cancel'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmitClick}
|
||||
disabled={disabled || !promptInput.trim()}
|
||||
className="px-4 py-1.5 bg-brand text-white rounded hover:bg-brand/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-secondary rounded transition-colors"
|
||||
title="Close expanded editor (Esc)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<div className="flex-1 min-h-0 p-4">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={promptInput}
|
||||
onChange={(e) => onPromptChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="w-full h-full px-4 py-3 bg-bg-primary border border-border-default rounded font-mono text-sm text-text-primary leading-relaxed focus:border-brand focus:outline-none resize-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer — Keyboard Shortcuts */}
|
||||
<div className="px-4 py-2 text-xs text-text-muted border-t border-border-subtle shrink-0 flex items-center justify-between">
|
||||
<span>Esc to close · Ctrl+Enter to send · Clear to reset</span>
|
||||
<span className="font-mono">{promptInput.length} chars</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { socketClient } from '../lib/socket';
|
||||
import { WorkspaceMode } from '../lib/types';
|
||||
import FileTreeNode from './FileTreeNode';
|
||||
@ -12,10 +12,6 @@ export interface FileTreeEntry {
|
||||
isHidden?: boolean;
|
||||
}
|
||||
|
||||
export interface FileTreeHandle {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
interface FileTreeProps {
|
||||
workspaceMode: WorkspaceMode;
|
||||
onFileSelect?: (path: string) => void;
|
||||
@ -28,7 +24,7 @@ interface FileTreeState {
|
||||
errors: Map<string, string>;
|
||||
}
|
||||
|
||||
const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree({ workspaceMode, onFileSelect }, ref) {
|
||||
export default function FileTree({ workspaceMode, onFileSelect }: FileTreeProps) {
|
||||
const [state, setState] = useState<FileTreeState>({
|
||||
directoryCache: new Map(),
|
||||
expandedPaths: new Set(),
|
||||
@ -38,21 +34,22 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree({ w
|
||||
|
||||
const [rootLoaded, setRootLoaded] = useState(false);
|
||||
|
||||
// Use refs to hold the latest state so loadDirectory always reads current values
|
||||
// without needing to be in its dependency array (which causes re-creation loops)
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
// Load root directory on mount
|
||||
useEffect(() => {
|
||||
if (!rootLoaded) {
|
||||
loadDirectory('');
|
||||
setRootLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadDirectory = useCallback(async (path: string) => {
|
||||
const currentState = stateRef.current;
|
||||
|
||||
// Don't reload if already cached
|
||||
if (currentState.directoryCache.has(path)) {
|
||||
if (state.directoryCache.has(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't reload if already loading
|
||||
if (currentState.loadingPaths.has(path)) {
|
||||
if (state.loadingPaths.has(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -113,28 +110,7 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree({ w
|
||||
};
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load root directory on mount
|
||||
useEffect(() => {
|
||||
if (!rootLoaded) {
|
||||
loadDirectory('');
|
||||
setRootLoaded(true);
|
||||
}
|
||||
}, [rootLoaded, loadDirectory]);
|
||||
|
||||
// Expose refresh method to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh: () => {
|
||||
setState({
|
||||
directoryCache: new Map(),
|
||||
expandedPaths: new Set(),
|
||||
loadingPaths: new Set(),
|
||||
errors: new Map(),
|
||||
});
|
||||
setRootLoaded(false);
|
||||
},
|
||||
}), []);
|
||||
}, [state.directoryCache, state.loadingPaths]);
|
||||
|
||||
const toggleExpand = useCallback((path: string) => {
|
||||
setState(prev => {
|
||||
@ -239,6 +215,4 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree({ w
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileTree;
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect } from "react";
|
||||
import { WorkspaceMode } from "../lib/types";
|
||||
import { socketClient } from "../lib/socket";
|
||||
import FileTree, { FileTreeHandle } from "./FileTree";
|
||||
import FileTree from "./FileTree";
|
||||
|
||||
interface FilesPanelProps {
|
||||
workspaceMode: WorkspaceMode;
|
||||
@ -12,125 +10,13 @@ export default function FilesPanel({ workspaceMode, onFileSelect }: FilesPanelPr
|
||||
const isReadOnly = workspaceMode === WorkspaceMode.Agent;
|
||||
const isReadWrite = workspaceMode === WorkspaceMode.User;
|
||||
|
||||
const fileTreeRef = useRef<FileTreeHandle>(null);
|
||||
|
||||
// New file creation state
|
||||
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
||||
const [newFilePath, setNewFilePath] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | undefined>(undefined);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus the input when the creation UI appears
|
||||
useEffect(() => {
|
||||
if (isCreatingFile && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isCreatingFile]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fileTreeRef.current?.refresh();
|
||||
}, []);
|
||||
|
||||
const handleStartCreate = useCallback(() => {
|
||||
if (!isReadWrite) return;
|
||||
setIsCreatingFile(true);
|
||||
setNewFilePath("");
|
||||
setCreateError(undefined);
|
||||
}, [isReadWrite]);
|
||||
|
||||
const handleCancelCreate = useCallback(() => {
|
||||
setIsCreatingFile(false);
|
||||
setNewFilePath("");
|
||||
setCreateError(undefined);
|
||||
}, []);
|
||||
|
||||
const handleConfirmCreate = useCallback(async () => {
|
||||
const trimmed = newFilePath.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// Strip leading slash if present (paths are relative to project root)
|
||||
const normalizedPath = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
|
||||
|
||||
setIsCreating(true);
|
||||
setCreateError(undefined);
|
||||
|
||||
try {
|
||||
const result = await socketClient.requestFileWrite({
|
||||
path: normalizedPath,
|
||||
content: "",
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setIsCreatingFile(false);
|
||||
setNewFilePath("");
|
||||
setCreateError(undefined);
|
||||
// Refresh the tree to show the new file, then open it in the editor
|
||||
fileTreeRef.current?.refresh();
|
||||
onFileSelect?.(normalizedPath);
|
||||
} else {
|
||||
setCreateError(result.error || "Failed to create file");
|
||||
}
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : "Failed to create file");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [newFilePath, onFileSelect]);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleConfirmCreate();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancelCreate();
|
||||
}
|
||||
},
|
||||
[handleConfirmCreate, handleCancelCreate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-t border-border-subtle flex flex-col flex-1 min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Files
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* New File button */}
|
||||
<button
|
||||
onClick={handleStartCreate}
|
||||
disabled={!isReadWrite}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
isReadWrite
|
||||
? "text-text-muted hover:text-text-primary hover:bg-bg-secondary"
|
||||
: "text-text-muted/30 cursor-not-allowed"
|
||||
}`}
|
||||
title={
|
||||
isReadWrite
|
||||
? "New file"
|
||||
: "Switch to User mode to create files"
|
||||
}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-secondary rounded transition-colors"
|
||||
title="Refresh file tree"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* RW indicator */}
|
||||
<span
|
||||
className={`w-6 h-6 flex items-center justify-center font-mono font-bold text-xs rounded border ${
|
||||
isReadWrite
|
||||
@ -141,7 +27,6 @@ export default function FilesPanel({ workspaceMode, onFileSelect }: FilesPanelPr
|
||||
>
|
||||
RW
|
||||
</span>
|
||||
{/* RO indicator */}
|
||||
<span
|
||||
className={`w-6 h-6 flex items-center justify-center font-mono font-bold text-xs rounded border ${
|
||||
isReadOnly
|
||||
@ -154,64 +39,12 @@ export default function FilesPanel({ workspaceMode, onFileSelect }: FilesPanelPr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New file creation input */}
|
||||
{isCreatingFile && (
|
||||
<div className="px-3 py-2 bg-bg-secondary border-b border-border-subtle">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={newFilePath}
|
||||
onChange={(e) => setNewFilePath(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="src/utils/helper.ts"
|
||||
disabled={isCreating}
|
||||
className="flex-1 bg-bg-primary border border-border-default rounded px-2 py-1 text-sm text-text-primary placeholder-text-muted/50 focus:outline-none focus:border-brand"
|
||||
/>
|
||||
{/* Confirm button */}
|
||||
<button
|
||||
onClick={handleConfirmCreate}
|
||||
disabled={isCreating || !newFilePath.trim()}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
isCreating || !newFilePath.trim()
|
||||
? "text-text-muted/30 cursor-not-allowed"
|
||||
: "text-green-500 hover:bg-bg-tertiary"
|
||||
}`}
|
||||
title="Create file"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Cancel button */}
|
||||
<button
|
||||
onClick={handleCancelCreate}
|
||||
disabled={isCreating}
|
||||
className="p-1 text-text-muted hover:text-red-400 hover:bg-bg-tertiary rounded transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{createError && (
|
||||
<p className="mt-1 text-xs text-red-400">{createError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File tree */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<FileTree
|
||||
ref={fileTreeRef}
|
||||
workspaceMode={workspaceMode}
|
||||
onFileSelect={onFileSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 bg-bg-tertiary border-t border-border-subtle">
|
||||
<p className="text-xs text-text-muted">
|
||||
{isReadOnly
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { useRef } from 'react';
|
||||
import LogRenderer from './LogRenderer';
|
||||
import type { LogRendererEntry } from './LogRenderer';
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
@ -17,8 +15,48 @@ interface LogPanelProps {
|
||||
onToggleExpand: () => void;
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
debug: 'text-gray-500',
|
||||
info: 'text-green-400',
|
||||
warn: 'text-yellow-400',
|
||||
alert: 'text-red-400',
|
||||
error: 'bg-red-800 text-white',
|
||||
crit: 'bg-red-800 text-yellow-300',
|
||||
fatal: 'bg-red-800 text-gray-400',
|
||||
};
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
const ss = String(date.getSeconds()).padStart(2, '0');
|
||||
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
||||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
export default function LogPanel({ logs, expanded, onToggleExpand }: LogPanelProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [expandedMetadata, setExpandedMetadata] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const toggleMetadata = (id: string) => {
|
||||
setExpandedMetadata(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -38,7 +76,50 @@ export default function LogPanel({ logs, expanded, onToggleExpand }: LogPanelPro
|
||||
{expanded ? '▾' : '▴'}
|
||||
</button>
|
||||
</div>
|
||||
<LogRenderer ref={scrollRef} logs={logs as LogRendererEntry[]} />
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-2 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-text-muted">No log entries</div>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const levelClass = levelColors[entry.level] || 'text-text-secondary';
|
||||
return (
|
||||
<div key={entry.id} className="flex gap-2 py-0.5 hover:bg-bg-elevated/50">
|
||||
<span className="text-gray-600 shrink-0 select-none">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
<span className={`shrink-0 font-bold ${levelClass}`}>
|
||||
{entry.level.toUpperCase().padEnd(5)}
|
||||
</span>
|
||||
<span className="text-cyan-400 shrink-0">
|
||||
{entry.component}
|
||||
</span>
|
||||
<span className="text-text-secondary break-all min-w-0">
|
||||
{entry.message}
|
||||
</span>
|
||||
{entry.metadata != null && (
|
||||
<button
|
||||
onClick={() => toggleMetadata(entry.id)}
|
||||
className="shrink-0 text-text-muted hover:text-text-secondary transition-colors"
|
||||
title={expandedMetadata.has(entry.id) ? 'Hide metadata' : 'Show metadata'}
|
||||
>
|
||||
{expandedMetadata.has(entry.id) ? '⊟' : '⊞'}
|
||||
</button>
|
||||
)}
|
||||
{entry.metadata != null && expandedMetadata.has(entry.id) && (
|
||||
<div className="w-full text-text-muted pl-[1em] border-l border-border-subtle mt-0.5 mb-1">
|
||||
<pre className="whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(entry.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
import { useRef, useEffect, useState, forwardRef } from 'react';
|
||||
|
||||
export interface LogRendererEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
level: string;
|
||||
component: string;
|
||||
message: string;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
interface LogRendererProps {
|
||||
logs: LogRendererEntry[];
|
||||
maxEntries?: number;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
debug: 'text-gray-500',
|
||||
info: 'text-green-400',
|
||||
warn: 'text-yellow-400',
|
||||
alert: 'text-red-400',
|
||||
error: 'bg-red-800 text-white',
|
||||
crit: 'bg-red-800 text-yellow-300',
|
||||
fatal: 'bg-red-800 text-gray-400',
|
||||
};
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
const ss = String(date.getSeconds()).padStart(2, '0');
|
||||
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
||||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
const LogRenderer = forwardRef<HTMLDivElement, LogRendererProps>(
|
||||
({ logs, containerClassName }, ref) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [expandedMetadata, setExpandedMetadata] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const target = ref && 'current' in ref ? ref : scrollRef;
|
||||
if (target.current) {
|
||||
target.current.scrollTop = target.current.scrollHeight;
|
||||
}
|
||||
}, [logs, ref]);
|
||||
|
||||
const toggleMetadata = (id: string) => {
|
||||
setExpandedMetadata(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref || scrollRef}
|
||||
className={`flex-1 overflow-y-auto p-2 font-mono text-xs leading-relaxed ${containerClassName ?? ''}`}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-text-muted">No log entries</div>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const levelClass = levelColors[entry.level] || 'text-text-secondary';
|
||||
return (
|
||||
<div key={entry.id} className="flex gap-2 py-0.5 hover:bg-bg-elevated/50">
|
||||
<span className="text-gray-600 shrink-0 select-none">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
<span className={`shrink-0 font-bold ${levelClass}`}>
|
||||
{entry.level.toUpperCase().padEnd(5)}
|
||||
</span>
|
||||
<span className="text-cyan-400 shrink-0">
|
||||
{entry.component}
|
||||
</span>
|
||||
<span className="text-text-secondary break-all min-w-0">
|
||||
{entry.message}
|
||||
</span>
|
||||
{entry.metadata != null && (
|
||||
<button
|
||||
onClick={() => toggleMetadata(entry.id)}
|
||||
className="shrink-0 text-text-muted hover:text-text-secondary transition-colors"
|
||||
title={expandedMetadata.has(entry.id) ? 'Hide metadata' : 'Show metadata'}
|
||||
>
|
||||
{expandedMetadata.has(entry.id) ? '⊟' : '⊞'}
|
||||
</button>
|
||||
)}
|
||||
{entry.metadata != null && expandedMetadata.has(entry.id) && (
|
||||
<div className="w-full text-text-muted pl-[1em] border-l border-border-subtle mt-0.5 mb-1">
|
||||
<pre className="whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(entry.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
LogRenderer.displayName = 'LogRenderer';
|
||||
|
||||
export default LogRenderer;
|
||||
@ -1,43 +0,0 @@
|
||||
import type { Project } from '../lib/api';
|
||||
|
||||
interface ProjectPanelProps {
|
||||
project: Project | null;
|
||||
onNavigateToProject: () => void;
|
||||
}
|
||||
|
||||
export default function ProjectPanel({ project, onNavigateToProject }: ProjectPanelProps) {
|
||||
return (
|
||||
<div className="border-b border-border-subtle shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Project
|
||||
</h3>
|
||||
<button
|
||||
title="Project Manager"
|
||||
onClick={onNavigateToProject}
|
||||
className="text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{project ? (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Name</div>
|
||||
<div className="text-text-primary">{project.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Slug</div>
|
||||
<div className="font-mono text-xs text-text-secondary">{project.slug}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-text-muted text-sm">Loading...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,298 +0,0 @@
|
||||
import { ChatSessionMode, type AiProvider, type ChatSession, type AiModel } from '../lib/api';
|
||||
import { WorkspaceMode } from '../lib/types';
|
||||
import WorkspaceModeIndicator from './WorkspaceModeIndicator';
|
||||
|
||||
interface SessionPanelProps {
|
||||
session: ChatSession | null;
|
||||
workspaceMode: WorkspaceMode;
|
||||
sessionLocked: boolean;
|
||||
providers: AiProvider[];
|
||||
selectedProviderId: string;
|
||||
selectedModelId: string;
|
||||
sessionReasoningEffort: string;
|
||||
sessionNumCtx: number;
|
||||
isEditingProvider: boolean;
|
||||
editProviderId: string;
|
||||
editModelId: string;
|
||||
isUpdatingProvider: boolean;
|
||||
isEditingName: boolean;
|
||||
editName: string;
|
||||
isUpdatingName: boolean;
|
||||
isUpdatingMode: boolean;
|
||||
isUpdatingReasoning: boolean;
|
||||
isUpdatingNumCtx: boolean;
|
||||
onWorkspaceModeChange: (mode: WorkspaceMode) => void;
|
||||
onModeChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
onReasoningChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
onNumCtxChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onNumCtxCommit: (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => void;
|
||||
onStartEditProvider: () => void;
|
||||
onSaveProvider: () => void;
|
||||
onCancelEditProvider: () => void;
|
||||
onEditProviderIdChange: (id: string) => void;
|
||||
onEditModelIdChange: (id: string) => void;
|
||||
onStartEditName: () => void;
|
||||
onSaveName: () => void;
|
||||
onCancelEditName: () => void;
|
||||
onEditNameChange: (name: string) => void;
|
||||
getProviderName: (id: string) => string;
|
||||
getModelName: (providerId: string, modelId: string) => string;
|
||||
getSortedModels: (providerId: string) => AiProvider['models'];
|
||||
truncateModelName: (name: string, maxLength?: number) => string;
|
||||
getSelectedModelCapabilities: () => { hasThinking: boolean } | null;
|
||||
getSelectedModel: () => AiModel | null;
|
||||
}
|
||||
|
||||
export default function SessionPanel({
|
||||
session,
|
||||
workspaceMode,
|
||||
sessionLocked,
|
||||
providers,
|
||||
selectedProviderId,
|
||||
selectedModelId,
|
||||
sessionReasoningEffort,
|
||||
sessionNumCtx,
|
||||
isEditingProvider,
|
||||
editProviderId,
|
||||
editModelId,
|
||||
isUpdatingProvider,
|
||||
isEditingName,
|
||||
editName,
|
||||
isUpdatingName,
|
||||
isUpdatingMode,
|
||||
isUpdatingReasoning,
|
||||
isUpdatingNumCtx,
|
||||
onWorkspaceModeChange,
|
||||
onModeChange,
|
||||
onReasoningChange,
|
||||
onNumCtxChange,
|
||||
onNumCtxCommit,
|
||||
onStartEditProvider,
|
||||
onSaveProvider,
|
||||
onCancelEditProvider,
|
||||
onEditProviderIdChange,
|
||||
onEditModelIdChange,
|
||||
onStartEditName,
|
||||
onSaveName,
|
||||
onCancelEditName,
|
||||
onEditNameChange,
|
||||
getProviderName,
|
||||
getModelName,
|
||||
getSortedModels,
|
||||
truncateModelName,
|
||||
getSelectedModelCapabilities,
|
||||
getSelectedModel,
|
||||
}: SessionPanelProps) {
|
||||
return (
|
||||
<div className="border-b border-border-subtle shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Session
|
||||
</h3>
|
||||
<WorkspaceModeIndicator
|
||||
mode={workspaceMode}
|
||||
onChange={onWorkspaceModeChange}
|
||||
disabled={!sessionLocked || isEditingProvider}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Name</div>
|
||||
{isEditingName ? (
|
||||
<div className="flex gap-2 mt-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => onEditNameChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); onSaveName(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); onCancelEditName(); }
|
||||
}}
|
||||
className="flex-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
title="Save"
|
||||
onClick={onSaveName}
|
||||
disabled={!editName.trim() || isUpdatingName}
|
||||
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors self-center"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
title="Cancel"
|
||||
onClick={onCancelEditName}
|
||||
disabled={isUpdatingName}
|
||||
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors self-center"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 items-center mt-1">
|
||||
<div className="text-text-primary flex-1">{session?.name}</div>
|
||||
<button
|
||||
title="Edit name"
|
||||
onClick={onStartEditName}
|
||||
className="text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEditingProvider ? (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-text-muted">Provider</div>
|
||||
<select
|
||||
value={editProviderId || ''}
|
||||
onChange={(e) => { onEditProviderIdChange(e.target.value); onEditModelIdChange(''); }}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
|
||||
>
|
||||
<option value="">-- Select Provider --</option>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider._id} value={provider._id}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-text-muted">Model</div>
|
||||
<select
|
||||
value={editModelId || ''}
|
||||
onChange={(e) => onEditModelIdChange(e.target.value)}
|
||||
disabled={!editProviderId}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed truncate"
|
||||
>
|
||||
{!editProviderId ? (
|
||||
<option value="">Select provider first</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="">-- Select Model --</option>
|
||||
{getSortedModels(editProviderId).map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{truncateModelName(model.name)}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end gap-1 pb-1">
|
||||
<button
|
||||
title="Save"
|
||||
onClick={onSaveProvider}
|
||||
disabled={!editProviderId || !editModelId || isUpdatingProvider}
|
||||
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
title="Cancel"
|
||||
onClick={onCancelEditProvider}
|
||||
disabled={isUpdatingProvider}
|
||||
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-text-muted">Provider</div>
|
||||
<div className="text-text-primary text-sm mt-1">{getProviderName(selectedProviderId)}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-text-muted">Model</div>
|
||||
<div
|
||||
className="text-text-primary text-sm mt-1 truncate"
|
||||
title={getModelName(selectedProviderId, selectedModelId)}
|
||||
>
|
||||
{getModelName(selectedProviderId, selectedModelId)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
title="Change provider/model..."
|
||||
onClick={onStartEditProvider}
|
||||
className="self-end pb-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Mode</div>
|
||||
<select
|
||||
value={session?.mode || ChatSessionMode.Build}
|
||||
onChange={onModeChange}
|
||||
disabled={isUpdatingMode}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{Object.values(ChatSessionMode).map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{mode.charAt(0).toUpperCase() + mode.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Reasoning</div>
|
||||
<select
|
||||
value={sessionReasoningEffort}
|
||||
onChange={onReasoningChange}
|
||||
disabled={isUpdatingReasoning || !getSelectedModelCapabilities()?.hasThinking}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={
|
||||
!getSelectedModelCapabilities()?.hasThinking
|
||||
? "Selected model does not support reasoning"
|
||||
: "Controls how much the model thinks before responding"
|
||||
}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
{providers.find(p => p._id === selectedProviderId)?.apiType !== 'openai' && (
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Context Window</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<input
|
||||
type="range"
|
||||
min={16384}
|
||||
max={getSelectedModel()?.contextWindow || 131072}
|
||||
step={8192}
|
||||
value={sessionNumCtx}
|
||||
onChange={onNumCtxChange}
|
||||
onMouseUp={onNumCtxCommit}
|
||||
onTouchEnd={onNumCtxCommit}
|
||||
className="flex-1 h-2 bg-bg-tertiary rounded appearance-none cursor-pointer accent-brand"
|
||||
/>
|
||||
<span className="text-xs text-text-secondary w-16 text-right tabular-nums">
|
||||
{sessionNumCtx.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
import type { SubProcessStat } from '../lib/api';
|
||||
|
||||
interface SubProcessTableProps {
|
||||
processes: SubProcessStat[];
|
||||
onSelect?: (pid: number) => void;
|
||||
selectedPid?: number | null;
|
||||
}
|
||||
|
||||
const statusDot: Record<string, string> = {
|
||||
running: 'bg-green-500',
|
||||
stopped: 'bg-yellow-500',
|
||||
error: 'bg-red-500',
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
running: '● running',
|
||||
stopped: '● halted',
|
||||
error: '● error',
|
||||
};
|
||||
|
||||
export default function SubProcessTable({ processes, onSelect, selectedPid }: SubProcessTableProps) {
|
||||
if (processes.length === 0) {
|
||||
return (
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded text-center">
|
||||
<p className="text-text-muted">No managed subprocesses.</p>
|
||||
<p className="text-text-muted text-sm mt-1">
|
||||
The agent has not spawned any processes for this session.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const runningCount = processes.filter((p) => p.status === 'running').length;
|
||||
|
||||
return (
|
||||
<div className="bg-bg-secondary border border-border-default rounded overflow-hidden">
|
||||
<div className="p-2 border-b border-border-subtle bg-bg-tertiary flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
SubProcess Monitor ({runningCount} running)
|
||||
</h3>
|
||||
<span className="text-xs text-text-muted">{processes.length} total</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr className="border-b border-border-subtle text-text-muted">
|
||||
<th className="text-left p-2 font-semibold">PID</th>
|
||||
<th className="text-left p-2 font-semibold">CMD</th>
|
||||
<th className="text-left p-2 font-semibold">PROJECT</th>
|
||||
<th className="text-left p-2 font-semibold">STATUS</th>
|
||||
<th className="text-right p-2 font-semibold">UPDATED</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processes.map((proc) => (
|
||||
<tr
|
||||
key={proc.pid}
|
||||
onClick={() => onSelect?.(proc.pid)}
|
||||
className={`border-b border-border-subtle last:border-0 transition-colors ${
|
||||
selectedPid === proc.pid
|
||||
? 'bg-bg-elevated'
|
||||
: onSelect
|
||||
? 'cursor-pointer hover:bg-bg-elevated/50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<td className="p-2 text-text-primary">{proc.pid}</td>
|
||||
<td className="p-2 text-text-primary">{proc.command}</td>
|
||||
<td className="p-2 text-text-secondary">{proc.projectSlug}</td>
|
||||
<td className="p-2">
|
||||
<span className={`${statusDot[proc.status] ?? 'bg-gray-500'} inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle`} />
|
||||
<span className="text-text-secondary align-middle">{statusLabel[proc.status] ?? proc.status}</span>
|
||||
</td>
|
||||
<td className="p-2 text-text-muted text-right whitespace-nowrap">
|
||||
{new Date(proc.updatedAt).toLocaleTimeString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,130 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { marked } from "marked";
|
||||
import { formatToolCallSignature } from "../lib/toolCallFormat";
|
||||
|
||||
// Configure marked with breaks enabled (consistent with ChatTurn)
|
||||
marked.use({ breaks: true });
|
||||
|
||||
interface ToolCallDisplayProps {
|
||||
callId: string;
|
||||
name: string;
|
||||
parameters?: string;
|
||||
response?: string;
|
||||
createdAt?: string;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single tool call with expand/collapse behavior.
|
||||
*
|
||||
* Collapsed: ▸ toolName(param: value, ...)
|
||||
* Expanded: shows response (raw/markdown toggle) + metadata
|
||||
*/
|
||||
export default function ToolCallDisplay({
|
||||
callId,
|
||||
name,
|
||||
parameters,
|
||||
response,
|
||||
createdAt,
|
||||
defaultExpanded = false,
|
||||
}: ToolCallDisplayProps) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [renderMarkdown, setRenderMarkdown] = useState(false);
|
||||
const toggle = useCallback(() => setExpanded((p) => !p), []);
|
||||
|
||||
const signature = formatToolCallSignature(name, parameters);
|
||||
const hasResponse = !!response;
|
||||
|
||||
return (
|
||||
<div className="tool-call">
|
||||
{/* Header row — always visible, clickable to toggle */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="tool-call-header flex items-start gap-1.5 text-xs font-mono text-text-secondary hover:text-text-primary transition-colors w-full text-left"
|
||||
>
|
||||
<span className="text-brand shrink-0">{expanded ? "▾" : "▸"}</span>
|
||||
<span className="break-all">{signature}</span>
|
||||
{hasResponse && !expanded && (
|
||||
<span className="text-green-500 shrink-0 ml-1">✓</span>
|
||||
)}
|
||||
{expanded && createdAt && (
|
||||
<span className="text-text-muted shrink-0 ml-auto text-[10px]">
|
||||
{new Date(createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded detail panel */}
|
||||
{expanded && (
|
||||
<div className="tool-call-detail ml-4 mt-1 space-y-1">
|
||||
{/* Metadata row */}
|
||||
<div className="flex items-center gap-3 text-[10px] font-mono text-text-muted">
|
||||
<span>id: {callId}</span>
|
||||
{createdAt && (
|
||||
<span>{new Date(createdAt).toLocaleTimeString()}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Response block */}
|
||||
{hasResponse && (
|
||||
<div className="tool-call-response-container">
|
||||
{/* Response header with RAW/MD toggle */}
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-text-muted mb-0.5">
|
||||
<span>Response</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenderMarkdown(false);
|
||||
}}
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] transition-colors ${
|
||||
!renderMarkdown
|
||||
? "bg-bg-elevated text-text-primary border border-border-default"
|
||||
: "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
RAW
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenderMarkdown(true);
|
||||
}}
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] transition-colors ${
|
||||
renderMarkdown
|
||||
? "bg-bg-elevated text-text-primary border border-border-default"
|
||||
: "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
MD
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response content */}
|
||||
{renderMarkdown ? (
|
||||
<div
|
||||
className="tool-call-response-md gadget-markdown text-xs"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(response!) as string,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="tool-call-response-raw text-xs">
|
||||
{response}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No response indicator */}
|
||||
{!hasResponse && (
|
||||
<div className="text-[10px] font-mono text-text-muted italic">
|
||||
No response
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -56,44 +56,14 @@ button {
|
||||
}
|
||||
|
||||
.tool-call {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
margin-top: theme("spacing.4");
|
||||
margin-bottom: theme("spacing.4");
|
||||
}
|
||||
|
||||
.tool-call + .tool-call {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Tool call detail panel (expanded) */
|
||||
.tool-call-detail {
|
||||
border-left: 2px solid var(--color-border-default);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Raw response display — fixed-width, scrollable */
|
||||
.tool-call-response-raw {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
}
|
||||
|
||||
/* Markdown-rendered response display — scrollable */
|
||||
.tool-call-response-md {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
|
||||
@ -222,25 +222,6 @@ export interface AuthResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface ProjectSkill {
|
||||
_id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
modes: string[];
|
||||
}
|
||||
|
||||
export interface ProjectTask {
|
||||
_id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
selectedModel: string;
|
||||
mode: string;
|
||||
crontab: string;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
lastRun?: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
_id: string;
|
||||
createdAt: string;
|
||||
@ -249,10 +230,6 @@ export interface Project {
|
||||
name: string;
|
||||
slug: string;
|
||||
gitUrl?: string;
|
||||
description?: string;
|
||||
system?: string;
|
||||
skills: ProjectSkill[];
|
||||
tasks: ProjectTask[];
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
@ -269,8 +246,6 @@ export const projectApi = {
|
||||
get: (id: string) => api.get<Project>(`/api/v1/projects/${id}`),
|
||||
create: (data: { name: string; slug: string; gitUrl?: string }) =>
|
||||
api.post<Project>("/api/v1/projects", data),
|
||||
pull: (gitUrl: string) =>
|
||||
api.post<Project>("/api/v1/projects/pull", { gitUrl }),
|
||||
update: (
|
||||
id: string,
|
||||
data: Partial<{
|
||||
@ -278,29 +253,11 @@ export const projectApi = {
|
||||
slug: string;
|
||||
gitUrl: string;
|
||||
status: string;
|
||||
description: string;
|
||||
system: string;
|
||||
skills: ProjectSkill[];
|
||||
tasks: ProjectTask[];
|
||||
}>,
|
||||
) => api.put<Project>(`/api/v1/projects/${id}`, data),
|
||||
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
|
||||
};
|
||||
|
||||
export interface SubProcessStat {
|
||||
pid: number;
|
||||
command: string;
|
||||
args: string[];
|
||||
projectId: string;
|
||||
projectSlug: string;
|
||||
projectName: string;
|
||||
status: "running" | "stopped" | "error";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
|
||||
export interface DroneRegistration {
|
||||
_id: string;
|
||||
hostname: string;
|
||||
@ -387,7 +344,6 @@ export interface ChatSession {
|
||||
provider: string | AiProvider;
|
||||
selectedModel: string;
|
||||
reasoningEffort?: string;
|
||||
numCtx?: number;
|
||||
stats: ChatSessionStats;
|
||||
pins: Array<{ _id?: string; content: string }>;
|
||||
}
|
||||
@ -451,7 +407,6 @@ export interface ChatTurn {
|
||||
session: string | ChatSession;
|
||||
provider: string | AiProvider;
|
||||
llm: string;
|
||||
numCtx?: number;
|
||||
mode: ChatSessionMode;
|
||||
status: "processing" | "finished" | "aborted" | "error";
|
||||
prompts: ChatTurnPrompts;
|
||||
@ -469,9 +424,9 @@ export interface ChatTurn {
|
||||
}
|
||||
|
||||
export const chatSessionApi = {
|
||||
getAll: (projectId?: string, limit?: number) =>
|
||||
getAll: (projectId?: string) =>
|
||||
api.get<ChatSession[]>(
|
||||
`/api/v1/chat-sessions${projectId ? `?projectId=${projectId}` : ""}${limit ? `${projectId ? "&" : "?"}limit=${limit}` : ""}`,
|
||||
`/api/v1/chat-sessions${projectId ? `?projectId=${projectId}` : ""}`,
|
||||
),
|
||||
get: (id: string) => api.get<ChatSession>(`/api/v1/chat-sessions/${id}`),
|
||||
create: (data: {
|
||||
|
||||
@ -135,17 +135,6 @@ export interface SocketEvents {
|
||||
fileReadResponse: (path: string, content: string | null, error?: string) => void;
|
||||
fileWriteResponse: (path: string, success: boolean, error?: string) => void;
|
||||
status: (content: string) => void;
|
||||
"drone:log": (data: {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
component: string;
|
||||
message: string;
|
||||
metadata?: unknown;
|
||||
}) => void;
|
||||
"drone:status": (data: {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
}) => void;
|
||||
reconnect_attempt: (attempt: number) => void;
|
||||
reconnect_failed: () => void;
|
||||
reconnect: (attempt: number) => void;
|
||||
@ -314,14 +303,6 @@ class SocketClient {
|
||||
this.emit("status", content);
|
||||
});
|
||||
|
||||
this.socket.on("drone:log", (data: unknown) => {
|
||||
this.emit("drone:log", data as SocketEvents["drone:log"] extends (data: infer T) => void ? T : never);
|
||||
});
|
||||
|
||||
this.socket.on("drone:status", (data: unknown) => {
|
||||
this.emit("drone:status", data as SocketEvents["drone:status"] extends (data: infer T) => void ? T : never);
|
||||
});
|
||||
|
||||
this._socket.on("reconnect_attempt", (attempt: number) => {
|
||||
this.emit("reconnect_attempt", attempt);
|
||||
});
|
||||
@ -524,48 +505,6 @@ class SocketClient {
|
||||
});
|
||||
}
|
||||
|
||||
requestProcessStats(
|
||||
registrationId: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
processes?: { pid: number; command: string; args: string[]; projectId: string; projectSlug: string; projectName: string; status: string; createdAt: string; updatedAt: string; stdoutFileName: string; stderrFileName: string }[];
|
||||
message?: string;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit(
|
||||
"requestProcessStats",
|
||||
registrationId,
|
||||
(success: boolean, data?: { processes?: any[]; message?: string }) => {
|
||||
resolve({ success, processes: data?.processes, message: data?.message });
|
||||
},
|
||||
);
|
||||
} else {
|
||||
resolve({ success: false, message: "Socket not connected" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribeDrone(registrationId: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit("subscribeDrone", registrationId, resolve);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
unsubscribeDrone(registrationId: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit("unsubscribeDrone", registrationId, resolve);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a single sessionHeartbeat event to the server (which relays
|
||||
* it to the drone). The drone resets its 120-second timeout timer
|
||||
|
||||
@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Tool call formatting utilities.
|
||||
*
|
||||
* Formats tool call parameters as function-call signature syntax:
|
||||
* toolName(param1: "value", param2: 42, param3: {...})
|
||||
*/
|
||||
|
||||
/**
|
||||
* Truncate a string to maxLength, appending "..." if truncated.
|
||||
*/
|
||||
export function truncate(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
return value.slice(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single parameter value for display in a function-call signature.
|
||||
*
|
||||
* - strings: quoted with truncation at 120 chars
|
||||
* - numbers/booleans/null: bare
|
||||
* - objects: "{...}" truncated
|
||||
* - arrays: "[...]" truncated
|
||||
*/
|
||||
export function formatParamValue(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
if (typeof value === "boolean") return String(value);
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string") {
|
||||
const truncated = truncate(value, 120);
|
||||
// Only quote if it contains spaces or special chars, or if truncated
|
||||
// Always quote for consistency — function-call syntax expects it
|
||||
return `"${truncated}"`;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return "[...]";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return "{...}";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON parameters string and format it as a function-call signature.
|
||||
*
|
||||
* @example
|
||||
* formatToolCallSignature("file_read", '{"path": "src/foo.ts", "recursive": true}')
|
||||
* // → 'file_read(path: "src/foo.ts", recursive: true)'
|
||||
*
|
||||
* @example
|
||||
* formatToolCallSignature("list_files", undefined)
|
||||
* // → 'list_files()'
|
||||
*/
|
||||
export function formatToolCallSignature(
|
||||
name: string,
|
||||
parameters?: string
|
||||
): string {
|
||||
if (!parameters) return `${name}()`;
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(parameters);
|
||||
} catch {
|
||||
// If it's not valid JSON, show it as a raw string argument
|
||||
return `${name}(${truncate(parameters, 200)})`;
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
// Non-object JSON — show as single argument
|
||||
return `${name}(${formatParamValue(parsed)})`;
|
||||
}
|
||||
|
||||
const entries = Object.entries(parsed);
|
||||
if (entries.length === 0) return `${name}()`;
|
||||
|
||||
const params = entries
|
||||
.map(([key, val]) => `${key}: ${formatParamValue(val)}`)
|
||||
.join(", ");
|
||||
|
||||
return `${name}(${params})`;
|
||||
}
|
||||
@ -1,17 +1,14 @@
|
||||
import { useState, useEffect, useRef, useContext, useCallback } from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { BrushCleaning, Maximize2 } from 'lucide-react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { socketClient } from '../lib/socket';
|
||||
import { chatSessionApi, projectApi, providerApi, type ChatSession, type ChatTurn, type ChatTurnBlock, ChatTurnStats, ChatSessionMode, type AiProvider, type Project } from '../lib/api';
|
||||
import { WorkspaceMode } from '../lib/types';
|
||||
import SessionPanel from '../components/SessionPanel';
|
||||
import ProjectPanel from '../components/ProjectPanel';
|
||||
import WorkspaceModeIndicator from '../components/WorkspaceModeIndicator';
|
||||
import FilesPanel from '../components/FilesPanel';
|
||||
import LogPanel from '../components/LogPanel';
|
||||
import ChatTurnComponent from '../components/ChatTurn';
|
||||
import EditorPanel from '../components/EditorPanel';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import ExpandedPromptEditor from '../components/ExpandedPromptEditor';
|
||||
import { AppContext } from '../App';
|
||||
|
||||
interface LogEntry {
|
||||
@ -45,41 +42,20 @@ interface SubagentStreamState {
|
||||
stats?: ChatTurnStats;
|
||||
}
|
||||
|
||||
enum SessionStartupState {
|
||||
Loading = 'loading',
|
||||
RequestLock = 'requestLock',
|
||||
LockAcquired = 'lockAcquired',
|
||||
Ready = 'ready',
|
||||
LockFailed = 'lockFailed',
|
||||
}
|
||||
|
||||
interface RouterState {
|
||||
drone?: any; // DroneRegistration passed via router state
|
||||
project?: Project; // Project passed via router state (optional — can be fetched)
|
||||
}
|
||||
|
||||
export default function ChatSessionView() {
|
||||
const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const appContext = useContext(AppContext);
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [session, setSession] = useState<ChatSession | null>(null);
|
||||
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
||||
const [promptInput, setPromptInput] = useState(() => {
|
||||
if (sessionId) {
|
||||
const saved = localStorage.getItem(`dtp_draft_prompt_${sessionId}`);
|
||||
if (saved) return saved;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const [promptInput, setPromptInput] = useState('');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isAborting, setIsAborting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [startupState, setStartupState] = useState<SessionStartupState>(SessionStartupState.Loading);
|
||||
const [droneRegistration, setDroneRegistration] = useState<any>(null);
|
||||
const [sessionLocked, setSessionLocked] = useState(true);
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>(WorkspaceMode.Idle);
|
||||
const [isUpdatingMode, setIsUpdatingMode] = useState(false);
|
||||
const [isUpdatingProvider, setIsUpdatingProvider] = useState(false);
|
||||
@ -91,8 +67,6 @@ export default function ChatSessionView() {
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string>('');
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>('');
|
||||
const [sessionReasoningEffort, setSessionReasoningEffort] = useState<string>('off');
|
||||
const [sessionNumCtx, setSessionNumCtx] = useState<number>(131072);
|
||||
const [isUpdatingNumCtx, setIsUpdatingNumCtx] = useState(false);
|
||||
const [isEditingProvider, setIsEditingProvider] = useState(false);
|
||||
const [editProviderId, setEditProviderId] = useState('');
|
||||
const [editModelId, setEditModelId] = useState('');
|
||||
@ -103,7 +77,6 @@ export default function ChatSessionView() {
|
||||
const [isOtherTab, setIsOtherTab] = useState(false);
|
||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||
const [editorFilePath, setEditorFilePath] = useState<string | undefined>(undefined);
|
||||
const [isExpandedEditor, setIsExpandedEditor] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
@ -117,7 +90,6 @@ export default function ChatSessionView() {
|
||||
const subagentStateRef = useRef<Map<string, SubagentStreamState>>(new Map());
|
||||
const sessionRef = useRef<ChatSession | null>(null);
|
||||
const projectRef = useRef<Project | null>(null);
|
||||
const droneRegistrationRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessionData();
|
||||
@ -132,17 +104,6 @@ export default function ChatSessionView() {
|
||||
scrollToBottom();
|
||||
}, [turns]);
|
||||
|
||||
// Persist draft prompt to localStorage so it survives page refreshes and navigation
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
const key = `dtp_draft_prompt_${sessionId}`;
|
||||
if (promptInput) {
|
||||
localStorage.setItem(key, promptInput);
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}, [promptInput, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (toastTimerRef.current) {
|
||||
@ -163,19 +124,15 @@ export default function ChatSessionView() {
|
||||
projectRef.current = project;
|
||||
}, [project]);
|
||||
|
||||
// Start heartbeat when session+project are loaded
|
||||
useEffect(() => {
|
||||
droneRegistrationRef.current = droneRegistration;
|
||||
}, [droneRegistration]);
|
||||
|
||||
// Start heartbeat when startup is complete (Ready state)
|
||||
useEffect(() => {
|
||||
if (startupState === SessionStartupState.Ready) {
|
||||
if (session && project) {
|
||||
socketClient.startSessionHeartbeat();
|
||||
}
|
||||
return () => {
|
||||
socketClient.stopSessionHeartbeat();
|
||||
};
|
||||
}, [startupState]);
|
||||
}, [session, project]);
|
||||
|
||||
// Handle socket reconnection and tab lock
|
||||
const handleSocketConnect = useCallback(() => {
|
||||
@ -214,10 +171,12 @@ export default function ChatSessionView() {
|
||||
// Release session lock on unmount only
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (droneRegistrationRef.current && sessionRef.current && projectRef.current) {
|
||||
const droneJson = localStorage.getItem('dtp_drone_registration');
|
||||
if (droneJson && sessionRef.current && projectRef.current) {
|
||||
try {
|
||||
const registration = JSON.parse(droneJson);
|
||||
socketClient.releaseSessionLock(
|
||||
droneRegistrationRef.current,
|
||||
registration,
|
||||
projectRef.current,
|
||||
sessionRef.current,
|
||||
);
|
||||
@ -229,96 +188,36 @@ export default function ChatSessionView() {
|
||||
}, []);
|
||||
|
||||
const loadSessionData = async () => {
|
||||
setStartupState(SessionStartupState.Loading);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (!sessionId) return;
|
||||
if (sessionId) {
|
||||
const sessionData = await chatSessionApi.get(sessionId);
|
||||
setSession(sessionData);
|
||||
|
||||
// --- State: LOADING — Fetch session, project, turns, providers ---
|
||||
|
||||
const sessionData = await chatSessionApi.get(sessionId);
|
||||
setSession(sessionData);
|
||||
|
||||
// Resolve the drone registration: router state > localStorage fallback
|
||||
const routerState = location.state as RouterState | null;
|
||||
const droneFromRouter = routerState?.drone;
|
||||
let droneReg = droneFromRouter || null;
|
||||
if (!droneReg) {
|
||||
// Fallback to localStorage for page refresh recovery
|
||||
try {
|
||||
const stored = localStorage.getItem('dtp_drone_registration');
|
||||
if (stored) droneReg = JSON.parse(stored);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Resolve the project: router state > session data > API fetch
|
||||
const projectFromRouter = routerState?.project;
|
||||
const sessionProjectRef = sessionData.project;
|
||||
const projectObjectId = typeof sessionProjectRef === 'string' ? sessionProjectRef : sessionProjectRef?._id;
|
||||
|
||||
let projectData: Project | null = projectFromRouter || null;
|
||||
if (!projectData && projectObjectId) {
|
||||
projectData = await projectApi.get(projectObjectId);
|
||||
}
|
||||
setProject(projectData);
|
||||
|
||||
const turnsData = await chatSessionApi.getTurns(sessionId);
|
||||
setTurns(turnsData);
|
||||
|
||||
const allProviders = await providerApi.getAll();
|
||||
setProviders(allProviders.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
|
||||
const providerId = typeof sessionData.provider === 'string'
|
||||
? sessionData.provider
|
||||
: sessionData.provider?._id;
|
||||
setSelectedProviderId(providerId || '');
|
||||
setSelectedModelId(sessionData.selectedModel || '');
|
||||
setSessionReasoningEffort(sessionData.reasoningEffort || 'off');
|
||||
|
||||
// Initialize numCtx: persisted value wins, otherwise model's contextWindow
|
||||
if (sessionData.numCtx) {
|
||||
setSessionNumCtx(sessionData.numCtx);
|
||||
} else {
|
||||
const provider = allProviders.find(p => p._id === providerId);
|
||||
const model = provider?.models.find(m => m.id === sessionData.selectedModel);
|
||||
setSessionNumCtx(model?.contextWindow || 131072);
|
||||
}
|
||||
|
||||
// --- State: REQUEST_LOCK — Request the drone lock ---
|
||||
if (droneReg && projectData) {
|
||||
setStartupState(SessionStartupState.RequestLock);
|
||||
appContext?.setStatusMessage('Locking drone to session...');
|
||||
|
||||
const success = await socketClient.requestSessionLock(
|
||||
droneReg,
|
||||
projectData,
|
||||
sessionData,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
// Store drone registration in state and persist to localStorage for refresh recovery
|
||||
setDroneRegistration(droneReg);
|
||||
localStorage.setItem('dtp_drone_registration', JSON.stringify(droneReg));
|
||||
setStartupState(SessionStartupState.LockAcquired);
|
||||
setStartupState(SessionStartupState.Ready);
|
||||
appContext?.setStatusMessage('Session ready');
|
||||
} else {
|
||||
setStartupState(SessionStartupState.LockFailed);
|
||||
appContext?.setStatusMessage('Failed to lock drone — drone may be busy');
|
||||
return; // Don't proceed to ready state
|
||||
const projectRef = sessionData.project;
|
||||
const projectObjectId = typeof projectRef === 'string' ? projectRef : projectRef?._id;
|
||||
if (projectObjectId) {
|
||||
const projectData = await projectApi.get(projectObjectId);
|
||||
setProject(projectData);
|
||||
}
|
||||
} else {
|
||||
// No drone provided — skip lock step, go directly to Ready
|
||||
// (This handles the case where the view is loaded directly via URL without a drone,
|
||||
// e.g., for viewing completed sessions or task runs)
|
||||
setStartupState(SessionStartupState.Ready);
|
||||
}
|
||||
|
||||
// Set connection state to connecting - will update on socket connect
|
||||
setConnectionState('connecting');
|
||||
appContext?.setStatusMessage('Connecting...');
|
||||
const turnsData = await chatSessionApi.getTurns(sessionId);
|
||||
setTurns(turnsData);
|
||||
setSessionLocked(true);
|
||||
|
||||
const allProviders = await providerApi.getAll();
|
||||
setProviders(allProviders.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
|
||||
const providerId = typeof sessionData.provider === 'string'
|
||||
? sessionData.provider
|
||||
: sessionData.provider?._id;
|
||||
setSelectedProviderId(providerId || '');
|
||||
setSelectedModelId(sessionData.selectedModel || '');
|
||||
setSessionReasoningEffort(sessionData.reasoningEffort || 'off');
|
||||
|
||||
// Set connection state to connecting - will update on socket connect
|
||||
setConnectionState('connecting');
|
||||
appContext?.setStatusMessage('Connecting...');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load session');
|
||||
setConnectionState('error');
|
||||
@ -327,41 +226,6 @@ export default function ChatSessionView() {
|
||||
}
|
||||
};
|
||||
|
||||
const retrySessionLock = async () => {
|
||||
if (!droneRegistration && !location.state) {
|
||||
// Try localStorage again
|
||||
try {
|
||||
const stored = localStorage.getItem('dtp_drone_registration');
|
||||
if (stored) {
|
||||
const drone = JSON.parse(stored);
|
||||
setDroneRegistration(drone);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const droneReg = droneRegistrationRef.current;
|
||||
if (!droneReg || !sessionRef.current || !projectRef.current) return;
|
||||
|
||||
setStartupState(SessionStartupState.RequestLock);
|
||||
appContext?.setStatusMessage('Retrying drone lock...');
|
||||
|
||||
const success = await socketClient.requestSessionLock(
|
||||
droneReg,
|
||||
projectRef.current,
|
||||
sessionRef.current,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
localStorage.setItem('dtp_drone_registration', JSON.stringify(droneReg));
|
||||
setStartupState(SessionStartupState.LockAcquired);
|
||||
setStartupState(SessionStartupState.Ready);
|
||||
appContext?.setStatusMessage('Session ready');
|
||||
} else {
|
||||
setStartupState(SessionStartupState.LockFailed);
|
||||
appContext?.setStatusMessage('Failed to lock drone — drone may be busy');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSessionUpdated = useCallback((updates: Partial<ChatSession>) => {
|
||||
setSession(prev => prev ? { ...prev, ...updates } : null);
|
||||
}, []);
|
||||
@ -889,12 +753,14 @@ export default function ChatSessionView() {
|
||||
if (mode === workspaceMode) return;
|
||||
|
||||
try {
|
||||
if (!droneRegistration) {
|
||||
const droneJson = localStorage.getItem('dtp_drone_registration');
|
||||
if (!droneJson) {
|
||||
showToast('No drone registration found');
|
||||
return;
|
||||
}
|
||||
const registration = JSON.parse(droneJson);
|
||||
const result = await socketClient.requestWorkspaceMode(
|
||||
droneRegistration,
|
||||
registration,
|
||||
project,
|
||||
session,
|
||||
mode,
|
||||
@ -926,16 +792,13 @@ export default function ChatSessionView() {
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedModel = useCallback(() => {
|
||||
const getSelectedModelCapabilities = useCallback(() => {
|
||||
const provider = providers.find(p => p._id === selectedProviderId);
|
||||
if (!provider) return null;
|
||||
return provider.models.find(m => m.id === selectedModelId) || null;
|
||||
const model = provider.models.find(m => m.id === selectedModelId);
|
||||
return model?.capabilities || null;
|
||||
}, [providers, selectedProviderId, selectedModelId]);
|
||||
|
||||
const getSelectedModelCapabilities = useCallback(() => {
|
||||
return getSelectedModel()?.capabilities || null;
|
||||
}, [getSelectedModel]);
|
||||
|
||||
const handleReasoningChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (!session) return;
|
||||
|
||||
@ -957,28 +820,6 @@ export default function ChatSessionView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleNumCtxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSessionNumCtx(parseInt(e.target.value, 10));
|
||||
};
|
||||
|
||||
const handleNumCtxCommit = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
||||
if (!session || isUpdatingNumCtx) return;
|
||||
|
||||
const newValue = parseInt(e.currentTarget.value, 10);
|
||||
setIsUpdatingNumCtx(true);
|
||||
try {
|
||||
const updatedSession = await chatSessionApi.update(session._id, {
|
||||
numCtx: newValue
|
||||
});
|
||||
setSession(updatedSession);
|
||||
showToast(`Context window set to ${newValue.toLocaleString()}`);
|
||||
} catch (err) {
|
||||
showToast(`Failed to change context window: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsUpdatingNumCtx(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderName = (id: string) => {
|
||||
return providers.find(p => p._id === id)?.name ?? 'None';
|
||||
};
|
||||
@ -1017,13 +858,6 @@ export default function ChatSessionView() {
|
||||
setSession(updatedSession);
|
||||
setSelectedProviderId(editProviderId);
|
||||
setSelectedModelId(editModelId);
|
||||
|
||||
// Reset numCtx to new model's max context window
|
||||
const newProvider = providers.find(p => p._id === editProviderId);
|
||||
const newModel = newProvider?.models.find(m => m.id === editModelId);
|
||||
const newMaxCtx = newModel?.contextWindow || 131072;
|
||||
setSessionNumCtx(newMaxCtx);
|
||||
|
||||
setIsEditingProvider(false);
|
||||
showToast('Provider and model updated');
|
||||
} catch (err) {
|
||||
@ -1189,8 +1023,6 @@ export default function ChatSessionView() {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const sessionLocked = startupState === SessionStartupState.Ready;
|
||||
|
||||
const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked || !session?.selectedModel || isEditingProvider;
|
||||
const promptPlaceholder = isEditingProvider
|
||||
? 'Save or cancel provider changes to continue.'
|
||||
@ -1200,6 +1032,14 @@ export default function ChatSessionView() {
|
||||
? 'Please select a model first.'
|
||||
: 'Enter your prompt... (Shift+Enter for new line)';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-bg-primary">
|
||||
<p className="text-text-muted">Loading chat session...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !session) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-bg-primary">
|
||||
@ -1235,47 +1075,6 @@ export default function ChatSessionView() {
|
||||
);
|
||||
}
|
||||
|
||||
// Render lock-failed state
|
||||
if (startupState === SessionStartupState.LockFailed) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-bg-primary">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-2">Failed to Lock Drone</p>
|
||||
<p className="text-text-secondary mb-4 text-sm">
|
||||
The drone may be busy with another session or unavailable.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={retrySessionLock}
|
||||
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Retry Lock
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/projects/${projectId}`)}
|
||||
className="px-4 py-2 border border-border-default text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors"
|
||||
>
|
||||
Back to Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render loading/requesting-lock states
|
||||
if (startupState === SessionStartupState.Loading || startupState === SessionStartupState.RequestLock) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-bg-primary">
|
||||
<p className="text-text-muted">
|
||||
{startupState === SessionStartupState.Loading
|
||||
? 'Loading chat session...'
|
||||
: 'Locking drone to session...'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex bg-bg-primary overflow-hidden relative">
|
||||
{/* Toast notification */}
|
||||
@ -1291,25 +1090,8 @@ export default function ChatSessionView() {
|
||||
<div className="absolute inset-0 bg-bg-primary/80 z-30" />
|
||||
)}
|
||||
|
||||
{/* Expanded Prompt Editor (replaces Chat View when active) */}
|
||||
{isExpandedEditor ? (
|
||||
<ExpandedPromptEditor
|
||||
promptInput={promptInput}
|
||||
onPromptChange={setPromptInput}
|
||||
onSubmit={(e) => {
|
||||
handleSubmitPrompt(e);
|
||||
setIsExpandedEditor(false);
|
||||
}}
|
||||
onClose={() => setIsExpandedEditor(false)}
|
||||
onClearPrompt={() => setPromptInput('')}
|
||||
disabled={promptDisabled}
|
||||
placeholder={promptPlaceholder}
|
||||
isProcessing={isProcessing}
|
||||
isAborting={isAborting}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
) : editorFilePath ? (
|
||||
/* File Editor View (replaces Chat View when file is open) */
|
||||
{/* File Editor View (replaces Chat View when file is open) */}
|
||||
{editorFilePath ? (
|
||||
<ErrorBoundary>
|
||||
<EditorPanel
|
||||
workspaceMode={workspaceMode}
|
||||
@ -1318,7 +1100,7 @@ export default function ChatSessionView() {
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
/* Chat View */
|
||||
/* Chat View (75%) */
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
@ -1334,26 +1116,6 @@ export default function ChatSessionView() {
|
||||
{/* Prompt Input */}
|
||||
<div className="border-t border-border-subtle p-4 bg-bg-secondary shrink-0">
|
||||
<form onSubmit={handleSubmitPrompt} className="flex gap-2">
|
||||
<div className="flex flex-col self-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpandedEditor(true)}
|
||||
disabled={promptDisabled || !!editorFilePath}
|
||||
className="p-2 text-text-muted hover:text-text-primary hover:bg-bg-tertiary rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Open expanded prompt editor"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPromptInput('')}
|
||||
disabled={!promptInput}
|
||||
className="p-2 text-text-muted hover:text-text-primary hover:bg-bg-tertiary rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Clear prompt"
|
||||
>
|
||||
<BrushCleaning size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={promptInput}
|
||||
@ -1402,52 +1164,232 @@ export default function ChatSessionView() {
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="w-80 border-l border-border-subtle bg-bg-secondary flex flex-col">
|
||||
<SessionPanel
|
||||
session={session}
|
||||
workspaceMode={workspaceMode}
|
||||
sessionLocked={sessionLocked}
|
||||
providers={providers}
|
||||
selectedProviderId={selectedProviderId}
|
||||
selectedModelId={selectedModelId}
|
||||
sessionReasoningEffort={sessionReasoningEffort}
|
||||
sessionNumCtx={sessionNumCtx}
|
||||
isEditingProvider={isEditingProvider}
|
||||
editProviderId={editProviderId}
|
||||
editModelId={editModelId}
|
||||
isUpdatingProvider={isUpdatingProvider}
|
||||
isEditingName={isEditingName}
|
||||
editName={editName}
|
||||
isUpdatingName={isUpdatingName}
|
||||
isUpdatingMode={isUpdatingMode}
|
||||
isUpdatingReasoning={isUpdatingReasoning}
|
||||
isUpdatingNumCtx={isUpdatingNumCtx}
|
||||
onWorkspaceModeChange={handleWorkspaceModeChange}
|
||||
onModeChange={handleModeChange}
|
||||
onReasoningChange={handleReasoningChange}
|
||||
onNumCtxChange={handleNumCtxChange}
|
||||
onNumCtxCommit={handleNumCtxCommit}
|
||||
onStartEditProvider={handleStartEditProvider}
|
||||
onSaveProvider={handleSaveProvider}
|
||||
onCancelEditProvider={handleCancelEditProvider}
|
||||
onEditProviderIdChange={setEditProviderId}
|
||||
onEditModelIdChange={setEditModelId}
|
||||
onStartEditName={handleStartEditName}
|
||||
onSaveName={handleSaveName}
|
||||
onCancelEditName={handleCancelEditName}
|
||||
onEditNameChange={setEditName}
|
||||
getProviderName={getProviderName}
|
||||
getModelName={getModelName}
|
||||
getSortedModels={getSortedModels}
|
||||
truncateModelName={truncateModelName}
|
||||
getSelectedModelCapabilities={getSelectedModelCapabilities}
|
||||
getSelectedModel={getSelectedModel}
|
||||
/>
|
||||
{/* SESSION Panel */}
|
||||
<div className="border-b border-border-subtle shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Session
|
||||
</h3>
|
||||
<WorkspaceModeIndicator
|
||||
mode={workspaceMode}
|
||||
onChange={handleWorkspaceModeChange}
|
||||
disabled={!sessionLocked || isEditingProvider}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Name</div>
|
||||
{isEditingName ? (
|
||||
<div className="flex gap-2 mt-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleSaveName(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); handleCancelEditName(); }
|
||||
}}
|
||||
className="flex-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
title="Save"
|
||||
onClick={handleSaveName}
|
||||
disabled={!editName.trim() || isUpdatingName}
|
||||
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors self-center"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
title="Cancel"
|
||||
onClick={handleCancelEditName}
|
||||
disabled={isUpdatingName}
|
||||
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors self-center"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 items-center mt-1">
|
||||
<div className="text-text-primary flex-1">{session?.name}</div>
|
||||
<button
|
||||
title="Edit name"
|
||||
onClick={handleStartEditName}
|
||||
className="text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEditingProvider ? (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-text-muted">Provider</div>
|
||||
<select
|
||||
value={editProviderId || ''}
|
||||
onChange={(e) => { setEditProviderId(e.target.value); setEditModelId(''); }}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
|
||||
>
|
||||
<option value="">-- Select Provider --</option>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider._id} value={provider._id}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-text-muted">Model</div>
|
||||
<select
|
||||
value={editModelId || ''}
|
||||
onChange={(e) => setEditModelId(e.target.value)}
|
||||
disabled={!editProviderId}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed truncate"
|
||||
>
|
||||
{!editProviderId ? (
|
||||
<option value="">Select provider first</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="">-- Select Model --</option>
|
||||
{getSortedModels(editProviderId).map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{truncateModelName(model.name)}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end gap-1 pb-1">
|
||||
<button
|
||||
title="Save"
|
||||
onClick={handleSaveProvider}
|
||||
disabled={!editProviderId || !editModelId || isUpdatingProvider}
|
||||
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
title="Cancel"
|
||||
onClick={handleCancelEditProvider}
|
||||
disabled={isUpdatingProvider}
|
||||
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-text-muted">Provider</div>
|
||||
<div className="text-text-primary text-sm mt-1">{getProviderName(selectedProviderId)}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-text-muted">Model</div>
|
||||
<div
|
||||
className="text-text-primary text-sm mt-1 truncate"
|
||||
title={getModelName(selectedProviderId, selectedModelId)}
|
||||
>
|
||||
{getModelName(selectedProviderId, selectedModelId)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
title="Change provider/model..."
|
||||
onClick={handleStartEditProvider}
|
||||
className="self-end pb-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Mode</div>
|
||||
<select
|
||||
value={session?.mode || ChatSessionMode.Build}
|
||||
onChange={handleModeChange}
|
||||
disabled={isUpdatingMode}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{Object.values(ChatSessionMode).map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{mode.charAt(0).toUpperCase() + mode.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Reasoning</div>
|
||||
<select
|
||||
value={sessionReasoningEffort}
|
||||
onChange={handleReasoningChange}
|
||||
disabled={isUpdatingReasoning || !getSelectedModelCapabilities()?.hasThinking}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={
|
||||
!getSelectedModelCapabilities()?.hasThinking
|
||||
? "Selected model does not support reasoning"
|
||||
: "Controls how much the model thinks before responding"
|
||||
}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectPanel
|
||||
project={project}
|
||||
onNavigateToProject={() => navigate(`/projects/${project?.slug}`)}
|
||||
/>
|
||||
{/* PROJECT Panel */}
|
||||
<div className="border-b border-border-subtle shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Project
|
||||
</h3>
|
||||
<button
|
||||
title="Project Manager"
|
||||
onClick={() => navigate(`/projects/${project?.slug}`)}
|
||||
className="text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{project ? (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Name</div>
|
||||
<div className="text-text-primary">{project.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-muted">Slug</div>
|
||||
<div className="font-mono text-xs text-text-secondary">{project.slug}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-text-muted text-sm">Loading...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FILES Panel */}
|
||||
<FilesPanel
|
||||
workspaceMode={workspaceMode}
|
||||
onFileSelect={(path) => {
|
||||
|
||||
@ -1,46 +1,30 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import type { User, DroneRegistration, SubProcessStat } from '../lib/api';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { User, DroneRegistration } from '../lib/api';
|
||||
import { droneApi } from '../lib/api';
|
||||
import { socketClient } from '../lib/socket';
|
||||
import SubProcessTable from '../components/SubProcessTable';
|
||||
import DroneMonitor from '../components/DroneMonitor';
|
||||
import LogRenderer from '../components/LogRenderer';
|
||||
import type { LogRendererEntry } from '../components/LogRenderer';
|
||||
|
||||
interface DroneManagerProps {
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
}
|
||||
|
||||
let logIdCounter = 0;
|
||||
|
||||
export default function DroneManager({ user }: DroneManagerProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [allDrones, setAllDrones] = useState<DroneRegistration[]>([]);
|
||||
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [terminating, setTerminating] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
// SubProcess state
|
||||
const [processStats, setProcessStats] = useState<SubProcessStat[]>([]);
|
||||
const [selectedPid, setSelectedPid] = useState<number | null>(null);
|
||||
|
||||
// Live log state
|
||||
const [logEntries, setLogEntries] = useState<LogRendererEntry[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
// Placeholder log entries for testing scroll behavior
|
||||
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track cleanup
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const subscribedDroneRef = useRef<string | null>(null);
|
||||
const droneChangeGeneration = useRef(0);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
@ -50,81 +34,43 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
loadDrones();
|
||||
}, [user]);
|
||||
|
||||
// Socket lifecycle — subscribe, poll, listen when selectedDrone changes
|
||||
// Mock log generator for testing scroll behavior
|
||||
// TODO: Replace with live log streaming when backend is implemented
|
||||
useEffect(() => {
|
||||
const gen = ++droneChangeGeneration.current;
|
||||
|
||||
// Cleanup previous subscription
|
||||
if (subscribedDroneRef.current) {
|
||||
socketClient.unsubscribeDrone(subscribedDroneRef.current);
|
||||
subscribedDroneRef.current = null;
|
||||
}
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (!selectedDrone || selectedDrone.status === 'offline') {
|
||||
setProcessStats([]);
|
||||
if (!selectedDrone) {
|
||||
setLogEntries([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const regId = selectedDrone._id;
|
||||
subscribedDroneRef.current = regId;
|
||||
setProcessStats([]);
|
||||
setLogEntries([]);
|
||||
// Initialize with some placeholder entries
|
||||
const initialEntries: LogEntry[] = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: i,
|
||||
timestamp: new Date(Date.now() - (50 - i) * 1000).toLocaleTimeString(),
|
||||
message: `[PLACEHOLDER] Log entry ${i + 1} - This is mock data for testing scroll behavior`,
|
||||
}));
|
||||
setLogEntries(initialEntries);
|
||||
|
||||
// Subscribe to live drone events
|
||||
socketClient.subscribeDrone(regId);
|
||||
|
||||
// Listen for log events
|
||||
const handleLog = (data: { timestamp: string; level: string; component: string; message: string; metadata?: unknown }) => {
|
||||
if (droneChangeGeneration.current !== gen) return;
|
||||
logIdCounter++;
|
||||
setLogEntries((prev) => {
|
||||
const entry: LogRendererEntry = {
|
||||
id: `log-${logIdCounter}`,
|
||||
timestamp: new Date(data.timestamp),
|
||||
level: data.level,
|
||||
component: data.component,
|
||||
message: data.message,
|
||||
metadata: data.metadata,
|
||||
// Add a new entry every second for testing auto-scroll
|
||||
const interval = setInterval(() => {
|
||||
setLogEntries(prev => {
|
||||
const newEntry: LogEntry = {
|
||||
id: prev.length > 0 ? prev[prev.length - 1]!.id + 1 : 0,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: `[PLACEHOLDER] New log entry at ${new Date().toLocaleTimeString()} - Auto-generated for testing`,
|
||||
};
|
||||
const next = [...prev, entry];
|
||||
if (next.length > 200) {
|
||||
return next.slice(next.length - 200);
|
||||
const updated = [...prev, newEntry];
|
||||
// Keep only last 200 entries as per spec
|
||||
if (updated.length > 200) {
|
||||
return updated.slice(updated.length - 200);
|
||||
}
|
||||
return next;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
}, 1000);
|
||||
|
||||
socketClient.on('drone:log', handleLog);
|
||||
|
||||
// Poll process stats every 1s
|
||||
const poll = async () => {
|
||||
if (droneChangeGeneration.current !== gen) return;
|
||||
const result = await socketClient.requestProcessStats(regId);
|
||||
if (result.success && result.processes && droneChangeGeneration.current === gen) {
|
||||
setProcessStats(result.processes as SubProcessStat[]);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
pollIntervalRef.current = setInterval(poll, 1000);
|
||||
|
||||
return () => {
|
||||
// Mark this effect cycle as stale so async callbacks are no-ops
|
||||
droneChangeGeneration.current++;
|
||||
clearInterval(pollIntervalRef.current!);
|
||||
pollIntervalRef.current = null;
|
||||
socketClient.off('drone:log', handleLog);
|
||||
socketClient.unsubscribeDrone(regId);
|
||||
subscribedDroneRef.current = null;
|
||||
};
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedDrone]);
|
||||
|
||||
// Auto-scroll log to bottom when new entries arrive
|
||||
// Auto-scroll log to bottom when new entries arrive (if user hasn't scrolled up)
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
@ -134,6 +80,7 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
const handleScroll = () => {
|
||||
if (logContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
||||
// Check if user is near the bottom (within 100px)
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
|
||||
setAutoScroll(isNearBottom);
|
||||
}
|
||||
@ -143,17 +90,6 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
try {
|
||||
const data = await droneApi.getForManager();
|
||||
setAllDrones(data);
|
||||
|
||||
// Auto-select from route state if provided
|
||||
const state = location.state as { droneId?: string } | null;
|
||||
if (state?.droneId) {
|
||||
const match = data.find((d) => d._id === state.droneId);
|
||||
if (match) {
|
||||
setSelectedDrone(match);
|
||||
}
|
||||
// Clear route state to avoid re-selecting on re-render
|
||||
window.history.replaceState({}, '');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load drones', err);
|
||||
showToast('Failed to load drones', 'error');
|
||||
@ -169,12 +105,13 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
|
||||
const handleSelectDrone = (drone: DroneRegistration) => {
|
||||
setSelectedDrone(drone);
|
||||
setSelectedPid(null);
|
||||
setLogEntries([]); // Clear log when switching drones
|
||||
};
|
||||
|
||||
const handleTerminate = async () => {
|
||||
if (!selectedDrone) return;
|
||||
|
||||
// Show confirmation if drone is busy
|
||||
if (selectedDrone.status === 'busy') {
|
||||
const confirmed = confirm('Are you sure you want to terminate the drone? Any work or operations currently in progress may be lost.');
|
||||
if (!confirmed) return;
|
||||
@ -183,15 +120,17 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
setTerminating(selectedDrone._id);
|
||||
try {
|
||||
const result = await droneApi.terminate(selectedDrone._id);
|
||||
|
||||
|
||||
if (result.message) {
|
||||
showToast(result.message, result.success ? 'success' : 'error');
|
||||
} else {
|
||||
showToast('Drone terminated successfully', 'success');
|
||||
}
|
||||
|
||||
// Refresh drone list
|
||||
await loadDrones();
|
||||
|
||||
|
||||
// Clear selection if drone is now offline
|
||||
if (selectedDrone.status !== 'offline') {
|
||||
setSelectedDrone(null);
|
||||
}
|
||||
@ -216,21 +155,24 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex bg-bg-primary overflow-hidden">
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div className={`fixed top-16 right-4 z-50 px-4 py-3 rounded border ${
|
||||
toast.type === 'success'
|
||||
? 'bg-green-900/80 border-green-600 text-green-100'
|
||||
toast.type === 'success'
|
||||
? 'bg-green-900/80 border-green-600 text-green-100'
|
||||
: 'bg-red-900/80 border-red-600 text-red-100'
|
||||
}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left Panel - Drone Lists */}
|
||||
<aside className="w-80 border-r border-border-subtle bg-bg-secondary flex flex-col overflow-hidden">
|
||||
<div className="p-3 border-b border-border-subtle flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Drone Manager</h2>
|
||||
</div>
|
||||
|
||||
{/* Online Drones Section - 50% of list area */}
|
||||
<div className="flex flex-col min-h-0 flex-1" style={{ minHeight: 0 }}>
|
||||
<div className="p-2 border-b border-border-subtle flex-shrink-0 bg-bg-tertiary">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
@ -285,6 +227,7 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Offline Drones Section - 50% of list area */}
|
||||
<div className="flex flex-col min-h-0 flex-1" style={{ minHeight: 0 }}>
|
||||
<div className="p-2 border-t border-border-subtle flex-shrink-0 bg-bg-tertiary">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
@ -330,9 +273,11 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right Panel - Drone Details */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden bg-bg-primary">
|
||||
{selectedDrone ? (
|
||||
<>
|
||||
{/* Drone Info Card */}
|
||||
<div className="p-6 border-b border-border-subtle flex-shrink-0">
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@ -384,16 +329,23 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 min-h-0 space-y-4">
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<SubProcessTable
|
||||
processes={processStats}
|
||||
onSelect={setSelectedPid}
|
||||
selectedPid={selectedPid}
|
||||
/>
|
||||
<DroneMonitor visible={!!selectedDrone && selectedDrone.status !== 'offline'} />
|
||||
{/* Drone Monitor Placeholder */}
|
||||
<div className="flex-1 overflow-y-auto p-6 min-h-0">
|
||||
<div className="max-w-3xl mb-6">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
||||
Drone Monitor
|
||||
</h3>
|
||||
<div className="p-8 bg-bg-secondary border border-border-default rounded text-center">
|
||||
<p className="text-text-muted">
|
||||
Monitor charts coming soon.
|
||||
</p>
|
||||
<p className="text-text-muted text-sm mt-2">
|
||||
Memory usage, AI operations, and log production metrics will be displayed here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Viewer - Collapsible panel at bottom */}
|
||||
<div className="flex flex-col min-h-[300px] max-h-[400px] border-t border-border-subtle">
|
||||
<div className="p-2 bg-bg-secondary border-b border-border-subtle flex-shrink-0">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
@ -403,9 +355,24 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto"
|
||||
className="flex-1 overflow-y-auto p-3 bg-bg-primary font-mono text-xs min-h-0"
|
||||
>
|
||||
<LogRenderer logs={logEntries} containerClassName="min-h-full" />
|
||||
{logEntries.length === 0 ? (
|
||||
<div className="text-text-muted">
|
||||
<p>No log entries yet.</p>
|
||||
<p className="mt-2 text-text-secondary">
|
||||
{/* TODO: Remove this placeholder comment when live logs are implemented */}
|
||||
[PLACEHOLDER] Log entries will appear here when the drone is running.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
logEntries.map((entry) => (
|
||||
<div key={entry.id} className="py-1 border-b border-border-subtle last:border-0">
|
||||
<span className="text-text-muted mr-3">[{entry.timestamp}]</span>
|
||||
<span className="text-text-secondary">{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import type { User, Project, DroneRegistration, ChatSession, SubProcessStat } from "../lib/api";
|
||||
import { projectApi, droneApi, chatSessionApi } from "../lib/api";
|
||||
import { socketClient } from "../lib/socket";
|
||||
import type { User, Project, DroneRegistration } from "../lib/api";
|
||||
import { projectApi, droneApi } from "../lib/api";
|
||||
import Clock from "../components/Clock";
|
||||
import GadgetGrid from "../components/GadgetGrid";
|
||||
import LogRenderer from "../components/LogRenderer";
|
||||
import type { LogRendererEntry } from "../components/LogRenderer";
|
||||
|
||||
function SystemReady() {
|
||||
return (
|
||||
@ -43,59 +40,8 @@ function DroneInspector({
|
||||
drone: DroneRegistration;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [processStats, setProcessStats] = useState<SubProcessStat[]>([]);
|
||||
const [logEntries, setLogEntries] = useState<LogRendererEntry[]>([]);
|
||||
const mountedRef = useRef(true);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const regId = drone._id;
|
||||
|
||||
if (drone.status === 'offline') return;
|
||||
|
||||
socketClient.subscribeDrone(regId);
|
||||
|
||||
const handleLog = (data: { timestamp: string; level: string; component: string; message: string }) => {
|
||||
if (!mountedRef.current) return;
|
||||
setLogEntries((prev) => {
|
||||
const next = [...prev, {
|
||||
id: `insp-log-${prev.length}`,
|
||||
timestamp: new Date(data.timestamp),
|
||||
level: data.level,
|
||||
component: data.component,
|
||||
message: data.message,
|
||||
}];
|
||||
return next.length > 100 ? next.slice(next.length - 100) : next;
|
||||
});
|
||||
};
|
||||
|
||||
socketClient.on('drone:log', handleLog);
|
||||
|
||||
const poll = async () => {
|
||||
if (!mountedRef.current) return;
|
||||
const result = await socketClient.requestProcessStats(regId);
|
||||
if (result.success && result.processes && mountedRef.current) {
|
||||
setProcessStats(result.processes as SubProcessStat[]);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
pollRef.current = setInterval(poll, 1000);
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
socketClient.off('drone:log', handleLog);
|
||||
socketClient.unsubscribeDrone(regId);
|
||||
};
|
||||
}, [drone._id, drone.status]);
|
||||
|
||||
const runningCount = processStats.filter((p) => p.status === 'running').length;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-start justify-center p-8 overflow-y-auto">
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="max-w-lg w-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">Drone Inspector</h2>
|
||||
@ -134,16 +80,6 @@ function DroneInspector({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-text-muted">SubProcesses</div>
|
||||
<div className="text-text-primary text-sm">
|
||||
{runningCount > 0
|
||||
? `${runningCount} running`
|
||||
: processStats.length > 0
|
||||
? 'All stopped'
|
||||
: 'No managed processes'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-text-muted">Registered</div>
|
||||
<div className="text-text-primary text-sm">
|
||||
@ -151,30 +87,6 @@ function DroneInspector({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drone.status !== 'offline' && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => navigate('/drones', { state: { droneId: drone._id } })}
|
||||
className="w-full px-4 py-2 border border-border-default text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors text-sm"
|
||||
>
|
||||
Open in Drone Manager →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{drone.status !== 'offline' && logEntries.length > 0 && (
|
||||
<div className="mt-4 border border-border-default rounded overflow-hidden max-h-48">
|
||||
<div className="p-2 bg-bg-tertiary border-b border-border-subtle">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Live Log
|
||||
</h3>
|
||||
</div>
|
||||
<div className="h-40 overflow-y-auto">
|
||||
<LogRenderer logs={logEntries.slice(-50)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -188,9 +100,7 @@ function DashboardSidebar({
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [drones, setDrones] = useState<DroneRegistration[]>([]);
|
||||
const [recentChats, setRecentChats] = useState<ChatSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [noDroneMessage, setNoDroneMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@ -198,14 +108,12 @@ function DashboardSidebar({
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [projectsData, dronesData, recentChatsData] = await Promise.all([
|
||||
const [projectsData, dronesData] = await Promise.all([
|
||||
projectApi.getAll(),
|
||||
droneApi.getAll(),
|
||||
chatSessionApi.getAll(undefined, 10),
|
||||
]);
|
||||
setProjects(projectsData);
|
||||
setDrones(dronesData);
|
||||
setRecentChats(recentChatsData);
|
||||
} catch (err) {
|
||||
console.error("Failed to load dashboard data", err);
|
||||
} finally {
|
||||
@ -213,107 +121,10 @@ function DashboardSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const getProjectName = (session: ChatSession): string => {
|
||||
const projectRef = session.project;
|
||||
if (typeof projectRef === 'object' && projectRef !== null && 'name' in projectRef) {
|
||||
return projectRef.name;
|
||||
}
|
||||
return 'Unknown Project';
|
||||
};
|
||||
|
||||
const getProjectId = (session: ChatSession): string => {
|
||||
const projectRef = session.project;
|
||||
if (typeof projectRef === 'object' && projectRef !== null && '_id' in projectRef) {
|
||||
return projectRef._id;
|
||||
}
|
||||
return typeof projectRef === 'string' ? projectRef : '';
|
||||
};
|
||||
|
||||
const handleOpenChat = (session: ChatSession) => {
|
||||
if (!selectedDrone) {
|
||||
setNoDroneMessage('Select a drone first to resume a chat');
|
||||
setTimeout(() => setNoDroneMessage(null), 3000);
|
||||
return;
|
||||
}
|
||||
const projectId = getProjectId(session);
|
||||
navigate(`/projects/${projectId}/chat-session/${session._id}`, {
|
||||
state: {
|
||||
drone: selectedDrone,
|
||||
project: typeof session.project === 'object' ? session.project : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const modeBadgeColors: Record<string, string> = {
|
||||
plan: 'bg-blue-500/20 text-blue-400',
|
||||
build: 'bg-brand/20 text-brand',
|
||||
test: 'bg-green-500/20 text-green-400',
|
||||
ship: 'bg-purple-500/20 text-purple-400',
|
||||
dev: 'bg-orange-500/20 text-orange-400',
|
||||
};
|
||||
|
||||
const formatRelativeTime = (dateStr: string): string => {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-64 border-l border-border-subtle bg-bg-secondary overflow-y-auto">
|
||||
<Clock />
|
||||
|
||||
<div className="p-3 border-t border-border-subtle">
|
||||
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||
Recent Chats
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted px-2">Loading...</p>
|
||||
) : recentChats.length === 0 ? (
|
||||
<p className="text-sm text-text-muted px-2">No recent chats.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{recentChats.map((session) => (
|
||||
<button
|
||||
key={session._id}
|
||||
onClick={() => handleOpenChat(session)}
|
||||
className="w-full text-left px-2 py-1.5 text-sm text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="truncate font-medium text-xs">{session.name || 'Untitled'}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0 ${modeBadgeColors[session.mode] || 'bg-gray-500/20 text-gray-400'}`}>
|
||||
{session.mode}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-1 mt-0.5">
|
||||
<span className="text-[11px] text-text-muted truncate">{getProjectName(session)}</span>
|
||||
<span className="text-[10px] text-text-muted shrink-0">
|
||||
{formatRelativeTime(session.lastMessageAt || session.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{noDroneMessage && (
|
||||
<p className="text-xs text-yellow-400 mt-2 px-2">{noDroneMessage}</p>
|
||||
)}
|
||||
{selectedDrone && (
|
||||
<p className="text-[10px] text-text-muted mt-2 px-2 italic">
|
||||
Select a chat to resume with {selectedDrone.hostname}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-border-subtle">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||
<span>Projects</span>
|
||||
@ -419,6 +230,14 @@ function DashboardSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-border-subtle">
|
||||
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||
Recent Chats
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-text-muted px-2">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 210 KiB |
29
gadget-code/frontend/src/types/assets.d.ts
vendored
29
gadget-code/frontend/src/types/assets.d.ts
vendored
@ -1,29 +0,0 @@
|
||||
declare module "*.jpeg" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.jpg" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.png" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.gif" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.svg" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.webp" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
@ -3,6 +3,10 @@
|
||||
"version": "1.0.0",
|
||||
"description": "Gadget Code - A self-hosted Agentic Engineering Platform (AEP).",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"gadget-code": "./dist/web-cli.js",
|
||||
"gadget-code-web": "./dist/web-app.js"
|
||||
},
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "pnpm build:backend",
|
||||
|
||||
@ -285,15 +285,9 @@ importers:
|
||||
'@uiw/react-codemirror':
|
||||
specifier: ^4.25.9
|
||||
version: 4.25.9(@babel/runtime@7.29.2)(@codemirror/autocomplete@6.20.2)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.42.1)(codemirror@6.0.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
lucide-react:
|
||||
specifier: ^1.16.0
|
||||
version: 1.16.0(react@19.2.5)
|
||||
marked:
|
||||
specifier: ^16.4.2
|
||||
version: 16.4.2
|
||||
react:
|
||||
specifier: ^19.2.5
|
||||
version: 19.2.5
|
||||
slug:
|
||||
specifier: ^11.0.1
|
||||
version: 11.0.1
|
||||
@ -2338,11 +2332,6 @@ packages:
|
||||
resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lucide-react@1.16.0:
|
||||
resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
luxon@3.6.1:
|
||||
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==}
|
||||
engines: {node: '>=12'}
|
||||
@ -5498,10 +5487,6 @@ snapshots:
|
||||
|
||||
lru-cache@11.3.5: {}
|
||||
|
||||
lucide-react@1.16.0(react@19.2.5):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
luxon@3.6.1: {}
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
@ -49,13 +49,12 @@ class ChatSessionController extends DtpController {
|
||||
}
|
||||
|
||||
const projectId = req.query.projectId as string | undefined;
|
||||
const limit = parseInt(req.query.limit as string, 10) || 0;
|
||||
|
||||
let sessions;
|
||||
if (projectId) {
|
||||
sessions = await ChatSessionService.getByProject(projectId);
|
||||
} else {
|
||||
sessions = await ChatSessionService.getByUser(user._id, limit);
|
||||
sessions = await ChatSessionService.getByUser(user._id);
|
||||
}
|
||||
|
||||
res.json({
|
||||
@ -180,7 +179,6 @@ class ChatSessionController extends DtpController {
|
||||
selectedModel: string;
|
||||
mode: ChatSessionMode;
|
||||
reasoningEffort: "off" | "low" | "medium" | "high";
|
||||
numCtx: number;
|
||||
}> = {};
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
@ -206,12 +204,6 @@ class ChatSessionController extends DtpController {
|
||||
}
|
||||
allowedUpdates.reasoningEffort = updates.reasoningEffort;
|
||||
}
|
||||
if (updates.numCtx !== undefined) {
|
||||
if (typeof updates.numCtx !== "number" || !Number.isInteger(updates.numCtx) || updates.numCtx < 16384 || updates.numCtx % 8192 !== 0) {
|
||||
throw new Error(`Invalid numCtx: ${updates.numCtx}. Must be an integer >= 16384 and a multiple of 8192.`);
|
||||
}
|
||||
allowedUpdates.numCtx = updates.numCtx;
|
||||
}
|
||||
|
||||
const session = await ChatSessionService.update(
|
||||
res.locals.chatSession._id,
|
||||
|
||||
@ -28,11 +28,9 @@ export class ProjectApiControllerV1 extends DtpController {
|
||||
|
||||
this.router.get("/", this.getProjects.bind(this));
|
||||
this.router.post("/", this.createProject.bind(this));
|
||||
this.router.post("/pull", this.pullProject.bind(this));
|
||||
this.router.get("/:projectId", this.getProject.bind(this));
|
||||
this.router.put("/:projectId", this.updateProject.bind(this));
|
||||
this.router.delete("/:projectId", this.deleteProject.bind(this));
|
||||
this.router.patch("/:projectId/tasks/:taskId/lastRun", this.updateTaskLastRun.bind(this));
|
||||
}
|
||||
|
||||
async getProjects(req: Request, res: Response): Promise<void> {
|
||||
@ -83,65 +81,6 @@ export class ProjectApiControllerV1 extends DtpController {
|
||||
}
|
||||
}
|
||||
|
||||
async pullProject(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { gitUrl } = req.body;
|
||||
|
||||
if (!gitUrl || typeof gitUrl !== "string") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "gitUrl is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the URL is parseable
|
||||
const trimmedUrl = gitUrl.trim();
|
||||
const isSshFormat = trimmedUrl.includes(":") && !trimmedUrl.startsWith("http") && !trimmedUrl.startsWith("ssh://");
|
||||
if (!isSshFormat) {
|
||||
try {
|
||||
new URL(trimmedUrl);
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "invalid git URL format",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const project = await projectService.createFromGitUrl(req.user, trimmedUrl);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: project,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 400) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle duplicate gitUrl (unique partial index violation)
|
||||
if (error.code === 11000) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
message: "a project with this git URL already exists",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.error("failed to pull project", { error });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "failed to pull project",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getProject(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = req.params.projectId as string;
|
||||
@ -197,16 +136,12 @@ export class ProjectApiControllerV1 extends DtpController {
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, slug, gitUrl, status, description, system, skills, tasks } = req.body;
|
||||
const { name, slug, gitUrl, status } = req.body;
|
||||
const updated = await projectService.update(project, {
|
||||
name,
|
||||
slug,
|
||||
gitUrl,
|
||||
status: status as ProjectStatus,
|
||||
description,
|
||||
system,
|
||||
skills,
|
||||
tasks,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
@ -257,49 +192,6 @@ export class ProjectApiControllerV1 extends DtpController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateTaskLastRun(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const projectId = req.params.projectId as string;
|
||||
const taskId = req.params.taskId as string;
|
||||
const { sessionId } = req.body;
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "sessionId is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await projectService.findById(projectId);
|
||||
if (!project) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "project not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = project.user as IUser;
|
||||
if (user._id !== req.user._id) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "access denied",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await projectService.updateTaskLastRun(projectId, taskId, sessionId);
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
this.log.error("failed to update task lastRun", { error });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "failed to update task lastRun",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ProjectApiControllerV1;
|
||||
|
||||
@ -22,7 +22,6 @@ import {
|
||||
WorkspaceMode,
|
||||
SubmitPromptCallback,
|
||||
AbortWorkOrderCallback,
|
||||
SubProcessStat,
|
||||
} from "@gadget/api";
|
||||
|
||||
import ChatSession from "../models/chat-session.ts";
|
||||
@ -65,12 +64,6 @@ export class CodeSession extends SocketSession {
|
||||
this.socket.on("fileTreeRequest", this.onFileTreeRequest.bind(this));
|
||||
this.socket.on("fileReadRequest", this.onFileReadRequest.bind(this));
|
||||
this.socket.on("fileWriteRequest", this.onFileWriteRequest.bind(this));
|
||||
this.socket.on(
|
||||
"requestProcessStats",
|
||||
this.onRequestProcessStats.bind(this),
|
||||
);
|
||||
this.socket.on("subscribeDrone", this.onSubscribeDrone.bind(this));
|
||||
this.socket.on("unsubscribeDrone", this.onUnsubscribeDrone.bind(this));
|
||||
|
||||
// Check for active session on connect
|
||||
this.checkAndReestablishActiveSession();
|
||||
@ -508,62 +501,6 @@ export class CodeSession extends SocketSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the IDE sends a requestProcessStats event to request
|
||||
* subprocess status from a drone. Routes by registration ID (not by
|
||||
* selectedDrone) so the DroneManager can query without a session lock.
|
||||
*/
|
||||
onRequestProcessStats(
|
||||
registrationId: string,
|
||||
cb: (
|
||||
success: boolean,
|
||||
data?: { processes?: SubProcessStat[]; message?: string },
|
||||
) => void,
|
||||
): void {
|
||||
try {
|
||||
const droneSession =
|
||||
SocketService.getDroneSessionByRegistrationId(registrationId);
|
||||
droneSession.socket.emit(
|
||||
"requestProcessStats",
|
||||
(success: boolean, data?: { processes?: SubProcessStat[]; message?: string }) => {
|
||||
cb(success, data);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.log.error("failed to forward requestProcessStats to drone", {
|
||||
error,
|
||||
});
|
||||
cb(false, { message: "Drone not connected" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the IDE wants to subscribe to live events (log, status)
|
||||
* from a drone for monitoring purposes (no chat session lock required).
|
||||
*/
|
||||
onSubscribeDrone(registrationId: string, cb: (success: boolean) => void): void {
|
||||
try {
|
||||
SocketService.addDroneMonitor(registrationId, this.socket.id);
|
||||
cb(true);
|
||||
} catch (error) {
|
||||
this.log.error("failed to subscribe to drone events", { error });
|
||||
cb(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the IDE wants to unsubscribe from live drone events.
|
||||
*/
|
||||
onUnsubscribeDrone(registrationId: string, cb: (success: boolean) => void): void {
|
||||
try {
|
||||
SocketService.removeDroneMonitor(registrationId, this.socket.id);
|
||||
cb(true);
|
||||
} catch (error) {
|
||||
this.log.error("failed to unsubscribe from drone events", { error });
|
||||
cb(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the IDE sends a releaseSessionLock event to release a
|
||||
* previously-acquired session lock on a gadget-drone instance.
|
||||
|
||||
@ -81,64 +81,47 @@ export class DroneSession extends SocketSession {
|
||||
message: string,
|
||||
metadata?: unknown,
|
||||
): Promise<void> {
|
||||
// Route to chat session if one is active
|
||||
if (this.chatSessionId) {
|
||||
try {
|
||||
const codeSession = SocketService.getCodeSessionByChatSessionId(
|
||||
this.chatSessionId,
|
||||
);
|
||||
codeSession.onLog(timestamp, component, level, message, metadata);
|
||||
} catch (error) {
|
||||
await MessageQueue.enqueue(this.chatSessionId, {
|
||||
type: 'log',
|
||||
args: [timestamp, component, level, message, metadata],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.log.debug("queued log message", { chatSessionId: this.chatSessionId });
|
||||
}
|
||||
if (!this.chatSessionId) {
|
||||
this.log.warn("log event received but no chat session is active");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const codeSession = SocketService.getCodeSessionByChatSessionId(
|
||||
this.chatSessionId,
|
||||
);
|
||||
codeSession.onLog(timestamp, component, level, message, metadata);
|
||||
} catch (error) {
|
||||
// Routing failed - queue to Redis
|
||||
await MessageQueue.enqueue(this.chatSessionId, {
|
||||
type: 'log',
|
||||
args: [timestamp, component, level, message, metadata],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.log.debug("queued log message", { chatSessionId: this.chatSessionId });
|
||||
}
|
||||
|
||||
// Broadcast to monitoring sessions (DroneManager, DroneInspector, etc.)
|
||||
SocketService.broadcastToMonitors(
|
||||
"drone:log",
|
||||
this.registration._id,
|
||||
{
|
||||
timestamp: timestamp instanceof Date ? timestamp.toISOString() : String(timestamp),
|
||||
level: String(level),
|
||||
component: typeof component === 'object' && component !== null ? (component as any).name ?? String(component) : String(component),
|
||||
message,
|
||||
metadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async onStatus(message: string): Promise<void> {
|
||||
// Route to chat session if one is active
|
||||
if (this.chatSessionId) {
|
||||
try {
|
||||
const codeSession = SocketService.getCodeSessionByChatSessionId(
|
||||
this.chatSessionId,
|
||||
);
|
||||
codeSession.socket.emit("status", message);
|
||||
} catch (error) {
|
||||
await MessageQueue.enqueue(this.chatSessionId, {
|
||||
type: 'status',
|
||||
args: [message],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.log.debug("queued status message", { chatSessionId: this.chatSessionId });
|
||||
}
|
||||
if (!this.chatSessionId) {
|
||||
this.log.warn("status event received but no chat session is active");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const codeSession = SocketService.getCodeSessionByChatSessionId(
|
||||
this.chatSessionId,
|
||||
);
|
||||
codeSession.socket.emit("status", message);
|
||||
} catch (error) {
|
||||
// Routing failed - queue to Redis
|
||||
await MessageQueue.enqueue(this.chatSessionId, {
|
||||
type: 'status',
|
||||
args: [message],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.log.debug("queued status message", { chatSessionId: this.chatSessionId });
|
||||
}
|
||||
|
||||
// Broadcast to monitoring sessions
|
||||
SocketService.broadcastToMonitors(
|
||||
"drone:status",
|
||||
this.registration._id,
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -30,7 +30,6 @@ export const ChatSessionSchema = new Schema<IChatSession>({
|
||||
enum: ["off", "low", "medium", "high"],
|
||||
default: "off",
|
||||
},
|
||||
numCtx: { type: Number },
|
||||
stats: {
|
||||
turnCount: { type: Number, default: 0, required: true },
|
||||
toolCallCount: { type: Number, default: 0, required: true },
|
||||
|
||||
@ -69,7 +69,6 @@ export const ChatTurnSchema = new Schema<IChatTurn>({
|
||||
enum: ["off", "low", "medium", "high"],
|
||||
default: "off",
|
||||
},
|
||||
numCtx: { type: Number },
|
||||
mode: {
|
||||
type: String,
|
||||
enum: ChatSessionMode,
|
||||
|
||||
@ -4,34 +4,9 @@
|
||||
|
||||
import { Schema, model } from "mongoose";
|
||||
|
||||
import {
|
||||
ProjectStatus,
|
||||
IProject,
|
||||
IProjectSkill,
|
||||
ChatSessionMode,
|
||||
IProjectTask,
|
||||
} from "@gadget/api";
|
||||
import { ProjectStatus, IProject } from "@gadget/api";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const ProjectSkillSchema = new Schema<IProjectSkill>({
|
||||
_id: { type: String, default: () => nanoid() },
|
||||
name: { type: String, required: true },
|
||||
modes: { type: [String], enum: ChatSessionMode, required: true },
|
||||
content: { type: String, required: true },
|
||||
});
|
||||
|
||||
export const ProjectTaskSchema = new Schema<IProjectTask>({
|
||||
_id: { type: String, default: () => nanoid() },
|
||||
name: { type: String, required: true },
|
||||
provider: { type: String, required: true, ref: "AiProvider" },
|
||||
selectedModel: { type: String, required: true },
|
||||
mode: { type: String, enum: ChatSessionMode, required: true },
|
||||
crontab: { type: String, required: true },
|
||||
content: { type: String, required: true },
|
||||
enabled: { type: Boolean, default: true, required: true },
|
||||
lastRun: { type: String, ref: "ChatSession" },
|
||||
});
|
||||
|
||||
export const ProjectSchema = new Schema<IProject>({
|
||||
_id: { type: String, default: () => nanoid() },
|
||||
createdAt: { type: Date, default: Date.now, required: true },
|
||||
@ -45,10 +20,6 @@ export const ProjectSchema = new Schema<IProject>({
|
||||
required: true,
|
||||
},
|
||||
gitUrl: { type: String },
|
||||
description: { type: String },
|
||||
system: { type: String },
|
||||
skills: { type: [ProjectSkillSchema], default: [], required: true },
|
||||
tasks: { type: [ProjectTaskSchema], default: [], required: true },
|
||||
});
|
||||
|
||||
ProjectSchema.index(
|
||||
|
||||
@ -178,22 +178,16 @@ class ChatSessionService extends DtpService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets chat sessions for a user, sorted by most recently active.
|
||||
* @param limit Maximum number of sessions to return (0 = no limit)
|
||||
* Gets all chat sessions for a user.
|
||||
*/
|
||||
async getByUser(userId: GadgetId, limit: number = 0): Promise<IChatSession[]> {
|
||||
let query = ChatSession.find({ user: userId })
|
||||
async getByUser(userId: GadgetId): Promise<IChatSession[]> {
|
||||
const sessions = await ChatSession.find({ user: userId })
|
||||
.populate("user", "-passwordSalt -password")
|
||||
.populate("project")
|
||||
.populate("provider", "+apiKey")
|
||||
.sort({ lastMessageAt: -1, createdAt: -1 })
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
if (limit > 0) {
|
||||
query = query.limit(limit);
|
||||
}
|
||||
|
||||
const sessions = await query;
|
||||
return sessions as unknown as IChatSession[];
|
||||
}
|
||||
|
||||
@ -208,7 +202,6 @@ class ChatSessionService extends DtpService {
|
||||
selectedModel: string;
|
||||
mode: ChatSessionMode;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
numCtx: number;
|
||||
}>,
|
||||
): Promise<IChatSession> {
|
||||
const session = await ChatSession.findById(chatSessionId);
|
||||
@ -237,9 +230,6 @@ class ChatSessionService extends DtpService {
|
||||
if (updates.reasoningEffort !== undefined) {
|
||||
session.reasoningEffort = updates.reasoningEffort;
|
||||
}
|
||||
if (updates.numCtx !== undefined) {
|
||||
session.numCtx = updates.numCtx;
|
||||
}
|
||||
|
||||
await session.save();
|
||||
|
||||
@ -286,7 +276,6 @@ class ChatSessionService extends DtpService {
|
||||
provider: session.provider,
|
||||
llm: session.selectedModel,
|
||||
reasoningEffort: (session as IChatSession).reasoningEffort || "off",
|
||||
numCtx: (session as IChatSession).numCtx,
|
||||
mode: session.mode,
|
||||
status: ChatTurnStatus.Processing,
|
||||
prompts: {
|
||||
@ -328,22 +317,18 @@ class ChatSessionService extends DtpService {
|
||||
);
|
||||
|
||||
const common = {
|
||||
processManagementBlock: await fs.promises.readFile(
|
||||
path.join(commonDir, "process-management-block.md"),
|
||||
"utf-8",
|
||||
),
|
||||
scopeBlock: await fs.promises.readFile(
|
||||
path.join(commonDir, "scope-block.md"),
|
||||
"utf-8",
|
||||
),
|
||||
subagentsBlock: await fs.promises.readFile(
|
||||
path.join(commonDir, "subagents.md"),
|
||||
"utf-8",
|
||||
),
|
||||
toolsBlock: await fs.promises.readFile(
|
||||
path.join(commonDir, "tools-block.md"),
|
||||
"utf-8",
|
||||
),
|
||||
subagentsBlock: await fs.promises.readFile(
|
||||
path.join(commonDir, "subagents.md"),
|
||||
"utf-8",
|
||||
),
|
||||
};
|
||||
|
||||
/*
|
||||
@ -380,22 +365,12 @@ class ChatSessionService extends DtpService {
|
||||
);
|
||||
|
||||
let prompt = promptTemplate
|
||||
.replace("{{process_management_block}}", common.processManagementBlock)
|
||||
.replace("{{scope_block}}", common.scopeBlock)
|
||||
.replace("{{tools}}", common.toolsBlock)
|
||||
.replace("{{subagent_section}}", common.subagentsBlock)
|
||||
.replace("{{session_block}}", sessionBlock)
|
||||
.replace("{{persona_block}}", personaBlock);
|
||||
|
||||
/*
|
||||
* Project System Prompt — appended as an appendix when the project
|
||||
* has a `system` field configured. This allows project-specific
|
||||
* instructions to be injected without modifying mode templates.
|
||||
*/
|
||||
if (project.system && project.system.trim().length > 0) {
|
||||
prompt += `\n\n## PROJECT CONFIGURATION\n\n### System Instructions\n${project.system.trim()}`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import slug from "slug";
|
||||
|
||||
import { MongooseBaseQueryOptions, PopulateOptions } from "mongoose";
|
||||
|
||||
import { IProject, IProjectSkill, IProjectTask, IUser, ProjectStatus } from "@gadget/api";
|
||||
import { IProject, IUser, ProjectStatus } from "@gadget/api";
|
||||
import Project from "@/models/project.js";
|
||||
|
||||
import { DtpService } from "../lib/service.js";
|
||||
@ -16,10 +16,6 @@ export interface IProjectDefinition {
|
||||
slug: string;
|
||||
gitUrl?: string;
|
||||
status?: ProjectStatus;
|
||||
description?: string;
|
||||
system?: string;
|
||||
skills?: IProjectSkill[];
|
||||
tasks?: IProjectTask[];
|
||||
}
|
||||
|
||||
class ProjectService extends DtpService {
|
||||
@ -51,69 +47,6 @@ class ProjectService extends DtpService {
|
||||
this.log.info("service stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a git URL to extract the repository name.
|
||||
* Handles common formats:
|
||||
* https://github.com/user/repo.git
|
||||
* https://github.com/user/repo
|
||||
* git@github.com:user/repo.git
|
||||
* ssh://git@github.com/user/repo.git
|
||||
*/
|
||||
parseGitUrl(gitUrl: string): { name: string; slug: string } {
|
||||
const url = gitUrl.trim();
|
||||
let repoName: string;
|
||||
|
||||
// SSH format: git@host:owner/repo.git
|
||||
if (url.includes(":") && !url.startsWith("http") && !url.startsWith("ssh://")) {
|
||||
const parts = url.split(":").pop()!;
|
||||
repoName = parts.split("/").pop()!;
|
||||
} else {
|
||||
// HTTP(S) or ssh:// format
|
||||
const parsed = new URL(url);
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
repoName = segments[segments.length - 1] || "";
|
||||
}
|
||||
|
||||
// Strip .git suffix
|
||||
repoName = repoName.replace(/\.git$/, "");
|
||||
|
||||
return {
|
||||
name: repoName,
|
||||
slug: slug(repoName),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a project from a git URL by parsing the URL to derive
|
||||
* the project name and slug automatically. Handles slug collisions
|
||||
* by appending a numeric suffix (e.g., my-repo-2).
|
||||
*/
|
||||
async createFromGitUrl(user: IUser, gitUrl: string): Promise<IProject> {
|
||||
const { name, slug: baseSlug } = this.parseGitUrl(gitUrl);
|
||||
|
||||
if (!name) {
|
||||
const error = new Error("could not derive project name from git URL");
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Resolve slug collisions by appending a numeric suffix
|
||||
let projectSlug = baseSlug;
|
||||
let suffix = 2;
|
||||
while (await Project.findOne({ slug: projectSlug, user: user._id })) {
|
||||
projectSlug = `${baseSlug}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
|
||||
this.log.info("creating project from git URL", { name, slug: projectSlug, gitUrl });
|
||||
|
||||
return this.create(user, {
|
||||
name,
|
||||
slug: projectSlug,
|
||||
gitUrl,
|
||||
});
|
||||
}
|
||||
|
||||
async create(user: IUser, definition: IProjectDefinition): Promise<IProject> {
|
||||
const NOW = new Date();
|
||||
|
||||
@ -162,41 +95,10 @@ class ProjectService extends DtpService {
|
||||
if (definition.gitUrl !== project.gitUrl) {
|
||||
update.$set.gitUrl = definition.gitUrl;
|
||||
}
|
||||
} else if (definition.gitUrl === undefined && definition.hasOwnProperty('gitUrl')) {
|
||||
// Only unset if explicitly provided as undefined/empty
|
||||
} else {
|
||||
update.$unset.gitUrl = 1;
|
||||
}
|
||||
|
||||
// Description — set or unset
|
||||
if (definition.description !== undefined) {
|
||||
if (definition.description !== project.description) {
|
||||
update.$set.description = definition.description;
|
||||
}
|
||||
}
|
||||
|
||||
// System prompt — set or unset
|
||||
if (definition.system !== undefined) {
|
||||
if (definition.system !== project.system) {
|
||||
update.$set.system = definition.system;
|
||||
}
|
||||
}
|
||||
|
||||
// Skills — full array replacement
|
||||
if (definition.skills !== undefined) {
|
||||
update.$set.skills = definition.skills;
|
||||
}
|
||||
|
||||
// Tasks — full array replacement
|
||||
if (definition.tasks !== undefined) {
|
||||
update.$set.tasks = definition.tasks;
|
||||
}
|
||||
|
||||
// Clean up empty $unset to avoid MongoDB errors
|
||||
if (Object.keys(update.$unset as Record<string, unknown>).length === 0) {
|
||||
delete (update as any).$unset;
|
||||
}
|
||||
|
||||
const newProject = await Project.findOneAndUpdate(
|
||||
{ _id: project._id },
|
||||
update,
|
||||
@ -257,19 +159,6 @@ class ProjectService extends DtpService {
|
||||
);
|
||||
}
|
||||
|
||||
async updateTaskLastRun(projectId: string, taskId: string, sessionId: string): Promise<void> {
|
||||
const result = await Project.updateOne(
|
||||
{ _id: projectId, "tasks._id": taskId },
|
||||
{ $set: { "tasks.$.lastRun": sessionId } },
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
throw new Error(`Task ${taskId} not found in project ${projectId}`);
|
||||
}
|
||||
|
||||
this.log.info("task lastRun updated", { projectId, taskId, sessionId });
|
||||
}
|
||||
|
||||
async delete(project: IProject): Promise<void> {
|
||||
await Project.deleteOne({ _id: project._id });
|
||||
}
|
||||
|
||||
@ -40,13 +40,6 @@ class SocketService extends DtpService {
|
||||
>();
|
||||
private codeSessionUserIndex: CodeSessionMap = new Map<string, CodeSession>();
|
||||
|
||||
/**
|
||||
* Tracks which code sessions (by socket ID) are monitoring which drones
|
||||
* (by registration ID). Used for broadcasting log/status events outside
|
||||
* the chat session routing path.
|
||||
*/
|
||||
private droneMonitorIndex: Map<string, Set<string>> = new Map();
|
||||
|
||||
private io?: Server<
|
||||
ClientToServerEvents,
|
||||
ServerToClientEvents,
|
||||
@ -282,62 +275,6 @@ class SocketService extends DtpService {
|
||||
return session;
|
||||
}
|
||||
|
||||
getDroneSessionByRegistrationId(registrationId: string): DroneSession {
|
||||
const session = this.droneRegistrationIndex.get(registrationId);
|
||||
if (!session) {
|
||||
const error = new Error("drone session not found");
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
addDroneMonitor(registrationId: string, socketId: string): void {
|
||||
let monitors = this.droneMonitorIndex.get(registrationId);
|
||||
if (!monitors) {
|
||||
monitors = new Set();
|
||||
this.droneMonitorIndex.set(registrationId, monitors);
|
||||
}
|
||||
monitors.add(socketId);
|
||||
}
|
||||
|
||||
removeDroneMonitor(registrationId: string, socketId: string): void {
|
||||
const monitors = this.droneMonitorIndex.get(registrationId);
|
||||
if (monitors) {
|
||||
monitors.delete(socketId);
|
||||
if (monitors.size === 0) {
|
||||
this.droneMonitorIndex.delete(registrationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToMonitors(
|
||||
event: string,
|
||||
registrationId: string,
|
||||
...args: unknown[]
|
||||
): void {
|
||||
const monitorSocketIds = this.droneMonitorIndex.get(registrationId);
|
||||
if (!monitorSocketIds || monitorSocketIds.size === 0) return;
|
||||
|
||||
for (const socketId of monitorSocketIds) {
|
||||
const codeSession = this.codeSessions.get(socketId);
|
||||
if (!codeSession) {
|
||||
// Stale entry — clean up
|
||||
monitorSocketIds.delete(socketId);
|
||||
continue;
|
||||
}
|
||||
if (!codeSession.socket.connected) {
|
||||
monitorSocketIds.delete(socketId);
|
||||
continue;
|
||||
}
|
||||
(codeSession.socket as any).emit(event, ...args);
|
||||
}
|
||||
|
||||
if (monitorSocketIds.size === 0) {
|
||||
this.droneMonitorIndex.delete(registrationId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a code session by its chat session ID for reverse lookup.
|
||||
*/
|
||||
|
||||
@ -19,14 +19,6 @@ vi.mock("../src/services/socket");
|
||||
vi.mock("../src/services/chat-session");
|
||||
vi.mock("../src/models/chat-turn");
|
||||
vi.mock("../src/models/chat-session");
|
||||
vi.mock("../src/lib/tab-lock", () => ({
|
||||
default: {
|
||||
acquire: vi.fn().mockResolvedValue({ success: true }),
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
refresh: vi.fn().mockResolvedValue(true),
|
||||
getInfo: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("CodeSession", () => {
|
||||
let mockSocket: any;
|
||||
@ -278,14 +270,6 @@ describe("CodeSession", () => {
|
||||
ChatSessionService.generateSessionNameFromPrompt,
|
||||
).mockResolvedValue(namedSession as any);
|
||||
|
||||
// Mock drone to accept the work order so the success callback runs
|
||||
mockDroneSession.socket.emit = vi.fn((event: string, ...args: any[]) => {
|
||||
const callback = args[args.length - 1];
|
||||
if (typeof callback === "function") {
|
||||
callback(true, "work order accepted");
|
||||
}
|
||||
});
|
||||
|
||||
await codeSession.onSubmitPrompt("build a rest api", cb);
|
||||
|
||||
expect(
|
||||
@ -350,7 +334,7 @@ describe("CodeSession", () => {
|
||||
});
|
||||
|
||||
describe("onRequestSessionLock", () => {
|
||||
it("should set selected drone, chat session, and project on success", async () => {
|
||||
it("should set selected drone, chat session, and project on success", () => {
|
||||
const mockDroneSession = {
|
||||
socket: {
|
||||
emit: vi.fn((event: string, ...args: any[]) => {
|
||||
@ -373,11 +357,7 @@ describe("CodeSession", () => {
|
||||
callback,
|
||||
);
|
||||
|
||||
// The inner callback is async (awaits TabLock.acquire), so we need to
|
||||
// wait for the microtask queue to flush before asserting
|
||||
await vi.waitFor(() => {
|
||||
expect(callback).toHaveBeenCalledWith(true, mockChatSession._id);
|
||||
});
|
||||
expect(callback).toHaveBeenCalledWith(true, mockChatSession._id);
|
||||
});
|
||||
|
||||
it("should not set session data on failure", () => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
@ -23,23 +23,6 @@ describe('Project API Endpoints', () => {
|
||||
expect(content).toContain('createProject');
|
||||
});
|
||||
|
||||
it('should have POST /pull route', () => {
|
||||
const controllerPath = path.join(ROOT_DIR, 'src', 'controllers', 'api', 'v1', 'project.ts');
|
||||
const content = fs.readFileSync(controllerPath, 'utf-8');
|
||||
expect(content).toContain('pullProject');
|
||||
expect(content).toContain('/pull');
|
||||
});
|
||||
|
||||
it('should register /pull route before /:projectId', () => {
|
||||
const controllerPath = path.join(ROOT_DIR, 'src', 'controllers', 'api', 'v1', 'project.ts');
|
||||
const content = fs.readFileSync(controllerPath, 'utf-8');
|
||||
const pullIndex = content.indexOf('"/pull"');
|
||||
const paramIndex = content.indexOf('"/:projectId"');
|
||||
expect(pullIndex).toBeGreaterThan(0);
|
||||
expect(paramIndex).toBeGreaterThan(0);
|
||||
expect(pullIndex).toBeLessThan(paramIndex);
|
||||
});
|
||||
|
||||
it('should use requireUser middleware', () => {
|
||||
const controllerPath = path.join(ROOT_DIR, 'src', 'controllers', 'api', 'v1', 'project.ts');
|
||||
const content = fs.readFileSync(controllerPath, 'utf-8');
|
||||
@ -72,18 +55,6 @@ describe('Project API Endpoints', () => {
|
||||
expect(content).toContain('getForUser');
|
||||
});
|
||||
|
||||
it('should have createFromGitUrl method', () => {
|
||||
const servicePath = path.join(ROOT_DIR, 'src', 'services', 'project.ts');
|
||||
const content = fs.readFileSync(servicePath, 'utf-8');
|
||||
expect(content).toContain('createFromGitUrl');
|
||||
});
|
||||
|
||||
it('should have parseGitUrl method', () => {
|
||||
const servicePath = path.join(ROOT_DIR, 'src', 'services', 'project.ts');
|
||||
const content = fs.readFileSync(servicePath, 'utf-8');
|
||||
expect(content).toContain('parseGitUrl');
|
||||
});
|
||||
|
||||
it('should populate user with password excluded', () => {
|
||||
const servicePath = path.join(ROOT_DIR, 'src', 'services', 'project.ts');
|
||||
const content = fs.readFileSync(servicePath, 'utf-8');
|
||||
@ -91,60 +62,6 @@ describe('Project API Endpoints', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Git URL Parsing', () => {
|
||||
// We import the service to test parseGitUrl directly
|
||||
let projectService: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import(path.join(ROOT_DIR, 'src', 'services', 'project.ts'));
|
||||
projectService = mod.default;
|
||||
});
|
||||
|
||||
it('should parse HTTPS URL with .git suffix', () => {
|
||||
const result = projectService.parseGitUrl('https://github.com/user/my-repo.git');
|
||||
expect(result.name).toBe('my-repo');
|
||||
expect(result.slug).toBe('my-repo');
|
||||
});
|
||||
|
||||
it('should parse HTTPS URL without .git suffix', () => {
|
||||
const result = projectService.parseGitUrl('https://github.com/user/my-repo');
|
||||
expect(result.name).toBe('my-repo');
|
||||
expect(result.slug).toBe('my-repo');
|
||||
});
|
||||
|
||||
it('should parse SSH format URL', () => {
|
||||
const result = projectService.parseGitUrl('git@github.com:user/my-repo.git');
|
||||
expect(result.name).toBe('my-repo');
|
||||
expect(result.slug).toBe('my-repo');
|
||||
});
|
||||
|
||||
it('should parse SSH URL with ssh:// prefix', () => {
|
||||
const result = projectService.parseGitUrl('ssh://git@github.com/user/my-repo.git');
|
||||
expect(result.name).toBe('my-repo');
|
||||
expect(result.slug).toBe('my-repo');
|
||||
});
|
||||
|
||||
it('should handle repo names with special characters', () => {
|
||||
const result = projectService.parseGitUrl('https://github.com/user/my_cool_repo.git');
|
||||
expect(result.name).toBe('my_cool_repo');
|
||||
// slug() removes underscores entirely
|
||||
expect(result.slug).toBe('mycoolrepo');
|
||||
});
|
||||
|
||||
it('should handle repo names with mixed case', () => {
|
||||
const result = projectService.parseGitUrl('https://github.com/user/MyRepo.git');
|
||||
expect(result.name).toBe('MyRepo');
|
||||
// slug() lowercases
|
||||
expect(result.slug).toBe('myrepo');
|
||||
});
|
||||
|
||||
it('should trim whitespace from URL', () => {
|
||||
const result = projectService.parseGitUrl(' https://github.com/user/my-repo.git ');
|
||||
expect(result.name).toBe('my-repo');
|
||||
expect(result.slug).toBe('my-repo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Frontend API Client', () => {
|
||||
it('should add Authorization header with token', () => {
|
||||
const apiPath = path.join(ROOT_DIR, 'frontend', 'src', 'lib', 'api.ts');
|
||||
@ -164,39 +81,6 @@ describe('Project API Endpoints', () => {
|
||||
const content = fs.readFileSync(apiPath, 'utf-8');
|
||||
expect(content).toContain('getAll');
|
||||
});
|
||||
|
||||
it('should have pull method for project API', () => {
|
||||
const apiPath = path.join(ROOT_DIR, 'frontend', 'src', 'lib', 'api.ts');
|
||||
const content = fs.readFileSync(apiPath, 'utf-8');
|
||||
expect(content).toContain('pull:');
|
||||
expect(content).toContain('/projects/pull');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pull Project UI', () => {
|
||||
it('should have PullProjectForm component', () => {
|
||||
const uiPath = path.join(ROOT_DIR, 'frontend', 'src', 'pages', 'ProjectManager.tsx');
|
||||
const content = fs.readFileSync(uiPath, 'utf-8');
|
||||
expect(content).toContain('PullProjectForm');
|
||||
});
|
||||
|
||||
it('should have Pull Project button in sidebar', () => {
|
||||
const uiPath = path.join(ROOT_DIR, 'frontend', 'src', 'pages', 'ProjectManager.tsx');
|
||||
const content = fs.readFileSync(uiPath, 'utf-8');
|
||||
expect(content).toContain('Pull Project');
|
||||
});
|
||||
|
||||
it('should have showPullForm state', () => {
|
||||
const uiPath = path.join(ROOT_DIR, 'frontend', 'src', 'pages', 'ProjectManager.tsx');
|
||||
const content = fs.readFileSync(uiPath, 'utf-8');
|
||||
expect(content).toContain('showPullForm');
|
||||
});
|
||||
|
||||
it('should have handleProjectPulled handler', () => {
|
||||
const uiPath = path.join(ROOT_DIR, 'frontend', 'src', 'pages', 'ProjectManager.tsx');
|
||||
const content = fs.readFileSync(uiPath, 'utf-8');
|
||||
expect(content).toContain('handleProjectPulled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interface', () => {
|
||||
|
||||
@ -1,231 +0,0 @@
|
||||
# Gadget Drone Sub-Processes
|
||||
|
||||
The Gadget Code agent manages child processes through gadget-drone's SubProcess system. In the Gadget ecosystem, we refer to a managed child process spawned by gadget-drone as a _SubProcess_.
|
||||
|
||||
## Phase Status
|
||||
|
||||
- **Phase 1 (Complete):** SubProcessService + SubprocessTool internal to drone.
|
||||
- **Phase 2 (Complete):** SubProcess observability in DroneManager and DroneInspector. Live log streaming via subscribe/broadcast pattern. Canvas-based resource gauges (CPU, Network, File I/O — mocked). See [Phase 2 Details](#phase-2-subprocess-observability) below.
|
||||
|
||||
## SubProcessService
|
||||
|
||||
[Source](../src/services/subprocess.ts)
|
||||
|
||||
`SubProcessService` is a singleton service that manages child processes on behalf of Gadget Drone. It is started and stopped as part of the standard service lifecycle in `gadget-drone.ts`. On stop, all managed subprocesses are killed (SIGINT with graceful timeout, falling back to SIGKILL) and their log files are removed.
|
||||
|
||||
### API
|
||||
|
||||
| Method | Signature | Description |
|
||||
|---|---|---|
|
||||
| `create` | `(project: IProject, cmd: string, args?: string[]) => Promise<SubProcess>` | Spawns a child process, starts writing stdout/stderr to log files under `<gadgetDir>/subprocess-logs/`. |
|
||||
| `ps` | `() => SubProcess[]` | Returns an array of all managed subprocesses. |
|
||||
| `summarize` | `(sp: SubProcess) => SubProcessSummary` | Returns a serializable summary (without ChildProcess/WriteStream refs). |
|
||||
| `killPid` | `(pid: number) => Promise<boolean>` | Kills a specific subprocess by PID and removes its log files. |
|
||||
| `killProject` | `(projectId: GadgetId) => Promise<void>` | Kills all subprocesses for a given project. |
|
||||
| `killSubProcess` | `(sp: SubProcess) => Promise<boolean>` | Kills a subprocess and removes its log files. |
|
||||
| `killAll` | `() => Promise<boolean>` | Kills all managed subprocesses and removes all log files. |
|
||||
| `haltPid` | `(pid: number) => Promise<boolean>` | Stops a subprocess by PID but **retains its log files** for inspection. |
|
||||
| `haltSubProcess` | `(sp: SubProcess) => Promise<boolean>` | Stops a subprocess but retains its log files. |
|
||||
| `getLog` | `(pid: number, stream: "stdout" \| "stderr") => Promise<string>` | Reads the full content of a subprocess's stdout or stderr log file. |
|
||||
|
||||
### Lifecycle
|
||||
|
||||
The service is registered in `GadgetDrone.startServices()` / `stopServices()`. During `stop()`, `killAll()` is called, ensuring no orphaned processes remain when the drone shuts down.
|
||||
|
||||
### Process Termination
|
||||
|
||||
Termination follows a graceful escalation:
|
||||
1. Send `SIGINT` to the process.
|
||||
2. Wait up to 5 seconds for the process to exit.
|
||||
3. If still alive, send `SIGKILL`.
|
||||
4. Wait up to another 5 seconds.
|
||||
5. Remove stdout/stderr listeners.
|
||||
6. Close log file streams.
|
||||
|
||||
For `kill`, log files are deleted after termination. For `halt`, log files are preserved.
|
||||
|
||||
## SubprocessTool
|
||||
|
||||
[Source](../src/tools/system/subprocess.ts)
|
||||
|
||||
The `subprocess` tool is registered in the agent's toolbox for modes: `Build`, `Test`, `Ship`, `Develop`. It provides a single function with a `cmd` parameter that dispatches to the appropriate operation.
|
||||
|
||||
### Tool Definition
|
||||
|
||||
**Name:** `subprocess`
|
||||
**Category:** `system`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `cmd` | string (enum) | yes | One of: `create`, `list`, `kill`, `killAll`, `halt`, `log` |
|
||||
| `command` | string | for `create` | The executable to spawn (e.g., `node`, `npm`, `python`) |
|
||||
| `args` | string[] | for `create` | Command-line arguments for the spawned process |
|
||||
| `pid` | number | for `kill`, `halt`, `log` | The managed process ID |
|
||||
| `which` | string (enum) | for `log` | `"stdout"` or `"stderr"` |
|
||||
|
||||
### Commands
|
||||
|
||||
#### `create`
|
||||
Spawns a new child process in the project directory. Logs stdout and stderr to `<gadgetDir>/subprocess-logs/`.
|
||||
|
||||
Response:
|
||||
```
|
||||
SUBPROCESS CREATED
|
||||
pid: 12345
|
||||
stdout: /path/to/.gadget/subprocess-logs/subproc-12345.stdout.log
|
||||
stderr: /path/to/.gadget/subprocess-logs/subproc-12345.stderr.log
|
||||
```
|
||||
|
||||
#### `list`
|
||||
Returns all currently managed subprocesses with their PIDs, project slugs, and timestamps.
|
||||
|
||||
Response:
|
||||
```
|
||||
SUBPROCESS LIST
|
||||
pid: 12345 | project: my-project | created: ... | updated: ...
|
||||
```
|
||||
|
||||
#### `kill`
|
||||
Terminates a subprocess and removes its log files. Uses SIGINT with graceful escalation.
|
||||
|
||||
Response:
|
||||
```
|
||||
SUBPROCESS KILLED
|
||||
pid: 12345
|
||||
```
|
||||
|
||||
#### `killAll`
|
||||
Terminates all managed subprocesses and removes all log files.
|
||||
|
||||
Response:
|
||||
```
|
||||
SUBPROCESS KILLALL
|
||||
All managed subprocesses have been terminated and their logs removed.
|
||||
```
|
||||
|
||||
#### `halt`
|
||||
Terminates a subprocess but retains its log files. Useful for inspecting logs after process exit.
|
||||
|
||||
Response:
|
||||
```
|
||||
SUBPROCESS HALTED
|
||||
pid: 12345
|
||||
The process has been stopped. Log files are retained for inspection.
|
||||
```
|
||||
|
||||
#### `log`
|
||||
Reads stdout or stderr output from a subprocess's log file.
|
||||
|
||||
Response:
|
||||
```
|
||||
SUBPROCESS LOG (stdout)
|
||||
pid: 12345
|
||||
---
|
||||
<log content>
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### System Prompts
|
||||
|
||||
All agent modes include a **Process Management** block in their system prompt. The block is loaded from [process-management-block.md](../../gadget-code/data/prompts/common/process-management-block.md) and rendered via `{{process_management_block}}` in the mode templates. This tells the agent about the `subprocess` tool and how to use it.
|
||||
|
||||
### Shutdown
|
||||
|
||||
`gadget-drone.ts` calls `SubProcessService.stop()` during shutdown, which triggers `killAll()` to clean up any remaining child processes.
|
||||
|
||||
## Tests
|
||||
|
||||
| File | What it covers |
|
||||
|---|---|
|
||||
| `src/services/subprocess.test.ts` | SubProcessService: create, ps, killPid, killAll, haltPid, getLog, error cases |
|
||||
| `src/tools/system/subprocess.test.ts` | SubprocessTool: all 6 commands, parameter validation, service error handling |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: SubProcess Observability
|
||||
|
||||
### Architecture
|
||||
|
||||
Phase 2 exposes the drone's managed subprocesses to the frontend via a **callback-chain pattern** over Socket.IO, identical to the existing `fileTreeRequest`/`fileReadRequest` flow:
|
||||
|
||||
```
|
||||
IDE (DroneManager.tsx)
|
||||
──emit("requestProcessStats", registrationId, cb)──▶
|
||||
│
|
||||
CodeSession.onRequestProcessStats()
|
||||
──droneSession.socket.emit("requestProcessStats", cb)──▶
|
||||
│
|
||||
GadgetDrone.onRequestProcessStats()
|
||||
│ SubProcessService.ps()
|
||||
│ .summarize() each
|
||||
│ → SubProcessStat[]
|
||||
│
|
||||
◀──cb(true, { processes })──
|
||||
◀──ack callback fires──
|
||||
◀──cb(success, data)──
|
||||
```
|
||||
|
||||
### Socket Protocol
|
||||
|
||||
| Direction | Event | Signature |
|
||||
|-----------|-------|-----------|
|
||||
| IDE → Backend | `requestProcessStats` | `(registrationId: string, cb: RequestProcessStatsCallback) => void` |
|
||||
| Backend → Drone | `requestProcessStats` | `(cb: RequestProcessStatsCallback) => void` (no registrationId — drone identifies itself) |
|
||||
|
||||
**Callback type:**
|
||||
```typescript
|
||||
type RequestProcessStatsCallback = (
|
||||
success: boolean,
|
||||
data?: { processes?: SubProcessStat[]; message?: string }
|
||||
) => void;
|
||||
```
|
||||
|
||||
### `SubProcessStat` Type
|
||||
|
||||
Defined in `packages/api/src/messages/subprocess.ts`:
|
||||
|
||||
```typescript
|
||||
interface SubProcessStat {
|
||||
pid: number;
|
||||
command: string;
|
||||
args: string[];
|
||||
projectId: GadgetId;
|
||||
projectSlug: string;
|
||||
projectName: string;
|
||||
status: "running" | "stopped" | "error";
|
||||
createdAt: string; // ISO 8601
|
||||
updatedAt: string; // ISO 8601
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Handler Behavior
|
||||
|
||||
The `onRequestProcessStats` handler in `gadget-drone.ts`:
|
||||
1. Calls `SubProcessService.ps()` to get all tracked subprocesses
|
||||
2. For each, calls `summarize()` for serializable fields
|
||||
3. Determines status: checks `sp.process.exitCode !== null || sp.process.killed`
|
||||
4. Extracts `command` and `args` from `sp.process.spawnargs`
|
||||
5. Returns `cb(true, { processes })` on success, `cb(false, { message })` on error
|
||||
|
||||
### Live Log Streaming (Subscribe/Broadcast)
|
||||
|
||||
In addition to request/response stats, the drone continuously emits `log` events through its `GadgetLogTransportSocket`. Phase 2 adds a **monitor broadcast** path:
|
||||
|
||||
1. **IDE sends** `subscribeDrone(registrationId)` → Backend adds IDE's socket to a monitor set
|
||||
2. **IDE sends** `unsubscribeDrone(registrationId)` → Backend removes from monitor set
|
||||
3. **Drone emits** `log` → `DroneSession.onLog()` forwards to chat session AND broadcasts `drone:log` to all monitoring sockets
|
||||
4. **Frontend** receives `drone:log` events and renders via `LogRenderer`
|
||||
|
||||
The monitor tracking lives in `SocketService.droneMonitorIndex` — a `Map<registrationId, Set<socketId>>`. Cleanup happens automatically on socket disconnect.
|
||||
|
||||
### Polling Interval
|
||||
|
||||
- **DroneManager**: 1-second interval polling `requestProcessStats`
|
||||
- **DroneInspector (dashboard)**: 1-second interval polling
|
||||
- Resource gauges (CPU, Network, File I/O): 200ms internal animation (mocked data)
|
||||
- All timers are killed on unmount or drone deselection
|
||||
@ -24,7 +24,6 @@
|
||||
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
|
||||
"dependencies": {
|
||||
"@gadget/ai": "workspace:*",
|
||||
"@gadget/ai-toolbox": "workspace:*",
|
||||
"@gadget/api": "workspace:*",
|
||||
"@gadget/config": "workspace:*",
|
||||
"@inquirer/prompts": "^8.4.2",
|
||||
@ -38,7 +37,6 @@
|
||||
"openai": "^6.34.0",
|
||||
"playwright": "1.59.1",
|
||||
"simple-git": "^3.36.0",
|
||||
"simplegit": "^1.0.2",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"turndown": "7.2.2"
|
||||
},
|
||||
|
||||
@ -10,19 +10,15 @@ import path from "node:path";
|
||||
import { io, ManagerOptions, SocketOptions, Socket } from "socket.io-client";
|
||||
import { input as inqInput, password as inqPassword } from "@inquirer/prompts";
|
||||
|
||||
import { detectWorkspace } from "@gadget/api";
|
||||
|
||||
import AgentService, { IAgentWorkOrder } from "./services/agent.ts";
|
||||
import AiService from "./services/ai.ts";
|
||||
import PlatformService from "./services/platform.ts";
|
||||
import SubProcessService from "./services/subprocess.ts";
|
||||
import WorkspaceService from "./services/workspace.ts";
|
||||
import {
|
||||
DroneStatus,
|
||||
GadgetLog,
|
||||
GadgetLogTransportSocket,
|
||||
IUser,
|
||||
SubProcessStat,
|
||||
} from "@gadget/api";
|
||||
|
||||
import { GadgetProcess } from "./lib/process.ts";
|
||||
@ -64,7 +60,6 @@ class GadgetDrone extends GadgetProcess {
|
||||
private socket: ClientSocket | undefined;
|
||||
private isShuttingDown: boolean = false;
|
||||
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private startupDir: string | undefined = undefined;
|
||||
|
||||
get name(): string {
|
||||
return "GadgetDrone";
|
||||
@ -86,38 +81,11 @@ class GadgetDrone extends GadgetProcess {
|
||||
this.hookProcessSignals();
|
||||
await this.startServices();
|
||||
|
||||
/*
|
||||
* Detect workspace directory by walking up the directory tree.
|
||||
* This allows starting the drone from anywhere within a workspace's hierarchy.
|
||||
*/
|
||||
|
||||
const detection = await detectWorkspace();
|
||||
this.startupDir = detection.startupDir;
|
||||
|
||||
if (!detection.found) {
|
||||
this.log.error("no workspace found", {
|
||||
startupDir: detection.startupDir,
|
||||
message: "Start the drone from within a managed workspace or its subdirectories. No .gadget/workspace.json found in any parent directory.",
|
||||
});
|
||||
throw new Error(
|
||||
"No workspace found. Cannot start drone in an unmanaged directory.",
|
||||
);
|
||||
}
|
||||
|
||||
// Change to the workspace directory for all operations
|
||||
const workspaceDir = detection.workspaceDir!;
|
||||
process.chdir(workspaceDir);
|
||||
|
||||
this.log.info("workspace detected", {
|
||||
workspaceDir,
|
||||
startupDir: detection.startupDir,
|
||||
navigated: workspaceDir !== detection.startupDir,
|
||||
});
|
||||
|
||||
/*
|
||||
* Initialize workspace directory structure and load/create workspace identity.
|
||||
*/
|
||||
|
||||
const workspaceDir = process.cwd();
|
||||
await WorkspaceService.initialize(workspaceDir);
|
||||
this.log.info("workspace initialized", {
|
||||
workspaceId: WorkspaceService.workspaceId,
|
||||
@ -200,16 +168,6 @@ class GadgetDrone extends GadgetProcess {
|
||||
|
||||
await this.stopServices();
|
||||
|
||||
/*
|
||||
* Restore the original startup directory before exiting.
|
||||
* This ensures the shell returns to the directory where the
|
||||
* drone was originally started.
|
||||
*/
|
||||
if (this.startupDir) {
|
||||
process.chdir(this.startupDir);
|
||||
this.log.info("restored startup directory", { startupDir: this.startupDir });
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@ -219,7 +177,6 @@ class GadgetDrone extends GadgetProcess {
|
||||
await AgentService.start();
|
||||
await AiService.start();
|
||||
await PlatformService.start();
|
||||
await SubProcessService.start();
|
||||
|
||||
this.log.info("services started");
|
||||
}
|
||||
@ -230,7 +187,6 @@ class GadgetDrone extends GadgetProcess {
|
||||
await AgentService.stop();
|
||||
await AiService.stop();
|
||||
await PlatformService.stop();
|
||||
await SubProcessService.stop();
|
||||
|
||||
this.log.info("services stopped");
|
||||
}
|
||||
@ -296,17 +252,25 @@ class GadgetDrone extends GadgetProcess {
|
||||
this.onReleaseSessionLock.bind(this),
|
||||
);
|
||||
this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this));
|
||||
this.socket.on("abortWorkOrder", this.onAbortWorkOrder.bind(this));
|
||||
this.socket.on(
|
||||
"abortWorkOrder",
|
||||
this.onAbortWorkOrder.bind(this),
|
||||
);
|
||||
this.socket.on(
|
||||
"requestTermination",
|
||||
this.onRequestTermination.bind(this),
|
||||
);
|
||||
this.socket.on("fileTreeRequest", this.onFileTreeRequest.bind(this));
|
||||
this.socket.on("fileReadRequest", this.onFileReadRequest.bind(this));
|
||||
this.socket.on("fileWriteRequest", this.onFileWriteRequest.bind(this));
|
||||
this.socket.on(
|
||||
"requestProcessStats",
|
||||
this.onRequestProcessStats.bind(this),
|
||||
"fileTreeRequest",
|
||||
this.onFileTreeRequest.bind(this),
|
||||
);
|
||||
this.socket.on(
|
||||
"fileReadRequest",
|
||||
this.onFileReadRequest.bind(this),
|
||||
);
|
||||
this.socket.on(
|
||||
"fileWriteRequest",
|
||||
this.onFileWriteRequest.bind(this),
|
||||
);
|
||||
|
||||
/*
|
||||
@ -411,7 +375,9 @@ class GadgetDrone extends GadgetProcess {
|
||||
/*
|
||||
* Check if this project is already deployed (by ID, not slug).
|
||||
*/
|
||||
const haveProjectInWorkspace = WorkspaceService.hasProjectById(project._id);
|
||||
const haveProjectInWorkspace = WorkspaceService.hasProjectById(
|
||||
project._id,
|
||||
);
|
||||
if (!haveProjectInWorkspace) {
|
||||
this.socket.emit("status", `deploying project [slug=${project.slug}]`);
|
||||
await WorkspaceService.deployProject(project);
|
||||
@ -580,11 +546,10 @@ class GadgetDrone extends GadgetProcess {
|
||||
}
|
||||
|
||||
if (newMode) {
|
||||
const previousMode = this.workspaceMode;
|
||||
this.workspaceMode = newMode;
|
||||
this.socket!.emit("workspaceModeChanged", this.workspaceMode);
|
||||
this.log.info("workspace mode changed", {
|
||||
previousMode,
|
||||
previousMode: this.workspaceMode,
|
||||
newMode,
|
||||
});
|
||||
return cb(true, this.workspaceMode);
|
||||
@ -761,10 +726,7 @@ class GadgetDrone extends GadgetProcess {
|
||||
|
||||
async onFileTreeRequest(
|
||||
args: FileTreeRequestArgs,
|
||||
cb: (
|
||||
success: boolean,
|
||||
data: { entries?: FileTreeEntry[]; error?: string },
|
||||
) => void,
|
||||
cb: (success: boolean, data: { entries?: FileTreeEntry[]; error?: string }) => void,
|
||||
): Promise<void> {
|
||||
if (!this.sessionLock) {
|
||||
return cb(false, { error: "No session lock active" });
|
||||
@ -773,17 +735,15 @@ class GadgetDrone extends GadgetProcess {
|
||||
// Get the project directory for this session
|
||||
const projectSlug = this.sessionLock.project.slug;
|
||||
const projectRoot = WorkspaceService.getProjectDirectory(projectSlug);
|
||||
|
||||
|
||||
this.log.debug("fileTreeRequest received", {
|
||||
projectSlug,
|
||||
projectRoot,
|
||||
requestedPath: args.path,
|
||||
});
|
||||
|
||||
|
||||
if (!projectRoot) {
|
||||
return cb(false, {
|
||||
error: `Project directory not found for slug: ${projectSlug}`,
|
||||
});
|
||||
return cb(false, { error: `Project directory not found for slug: ${projectSlug}` });
|
||||
}
|
||||
|
||||
// If no path specified, list from project root
|
||||
@ -795,10 +755,7 @@ class GadgetDrone extends GadgetProcess {
|
||||
// Security: Ensure resolved path is within project root
|
||||
const normalizedTarget = path.normalize(targetPath);
|
||||
const normalizedRoot = path.normalize(projectRoot);
|
||||
if (
|
||||
!normalizedTarget.startsWith(normalizedRoot + path.sep) &&
|
||||
normalizedTarget !== normalizedRoot
|
||||
) {
|
||||
if (!normalizedTarget.startsWith(normalizedRoot + path.sep) && normalizedTarget !== normalizedRoot) {
|
||||
this.log.warn("fileTreeRequest path traversal attempt", {
|
||||
targetPath: normalizedTarget,
|
||||
projectRoot: normalizedRoot,
|
||||
@ -828,13 +785,12 @@ class GadgetDrone extends GadgetProcess {
|
||||
|
||||
this.log.debug("fileTreeRequest completed", {
|
||||
entryCount: entries.length,
|
||||
entries: entries.slice(0, 10).map((e) => e.name), // Log first 10 entries
|
||||
entries: entries.slice(0, 10).map(e => e.name), // Log first 10 entries
|
||||
});
|
||||
|
||||
cb(true, { entries });
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.log.error("failed to list directory for file tree", {
|
||||
path: args.path,
|
||||
targetPath,
|
||||
@ -855,17 +811,15 @@ class GadgetDrone extends GadgetProcess {
|
||||
// Get the project directory for this session
|
||||
const projectSlug = this.sessionLock.project.slug;
|
||||
const projectRoot = WorkspaceService.getProjectDirectory(projectSlug);
|
||||
|
||||
|
||||
this.log.debug("fileReadRequest received", {
|
||||
projectSlug,
|
||||
projectRoot,
|
||||
requestedPath: args.path,
|
||||
});
|
||||
|
||||
|
||||
if (!projectRoot) {
|
||||
return cb(false, {
|
||||
error: `Project directory not found for slug: ${projectSlug}`,
|
||||
});
|
||||
return cb(false, { error: `Project directory not found for slug: ${projectSlug}` });
|
||||
}
|
||||
|
||||
// Resolve path relative to project root
|
||||
@ -874,10 +828,7 @@ class GadgetDrone extends GadgetProcess {
|
||||
// Security: Ensure resolved path is within project root
|
||||
const normalizedTarget = path.normalize(targetPath);
|
||||
const normalizedRoot = path.normalize(projectRoot);
|
||||
if (
|
||||
!normalizedTarget.startsWith(normalizedRoot + path.sep) &&
|
||||
normalizedTarget !== normalizedRoot
|
||||
) {
|
||||
if (!normalizedTarget.startsWith(normalizedRoot + path.sep) && normalizedTarget !== normalizedRoot) {
|
||||
this.log.warn("fileReadRequest path traversal attempt", {
|
||||
targetPath: normalizedTarget,
|
||||
projectRoot: normalizedRoot,
|
||||
@ -899,16 +850,14 @@ class GadgetDrone extends GadgetProcess {
|
||||
// Check file size (limit to 1MB)
|
||||
const maxSize = 1 * 1024 * 1024; // 1MB
|
||||
if (stat.size > maxSize) {
|
||||
return cb(false, {
|
||||
error: `File too large (${(stat.size / 1024 / 1024).toFixed(2)}MB). Maximum size is 1MB.`,
|
||||
});
|
||||
return cb(false, { error: `File too large (${(stat.size / 1024 / 1024).toFixed(2)}MB). Maximum size is 1MB.` });
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = await fs.readFile(targetPath, "utf-8");
|
||||
|
||||
|
||||
// Check for binary file (null bytes)
|
||||
if (content.includes("\0")) {
|
||||
if (content.includes('\0')) {
|
||||
return cb(false, { error: "Cannot edit binary files" });
|
||||
}
|
||||
|
||||
@ -919,8 +868,7 @@ class GadgetDrone extends GadgetProcess {
|
||||
|
||||
cb(true, { content });
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.log.error("failed to read file", {
|
||||
path: args.path,
|
||||
targetPath,
|
||||
@ -949,17 +897,15 @@ class GadgetDrone extends GadgetProcess {
|
||||
// Get the project directory for this session
|
||||
const projectSlug = this.sessionLock.project.slug;
|
||||
const projectRoot = WorkspaceService.getProjectDirectory(projectSlug);
|
||||
|
||||
|
||||
this.log.debug("fileWriteRequest received", {
|
||||
projectSlug,
|
||||
projectRoot,
|
||||
requestedPath: args.path,
|
||||
});
|
||||
|
||||
|
||||
if (!projectRoot) {
|
||||
return cb(false, {
|
||||
error: `Project directory not found for slug: ${projectSlug}`,
|
||||
});
|
||||
return cb(false, { error: `Project directory not found for slug: ${projectSlug}` });
|
||||
}
|
||||
|
||||
// Resolve path relative to project root
|
||||
@ -968,10 +914,7 @@ class GadgetDrone extends GadgetProcess {
|
||||
// Security: Ensure resolved path is within project root
|
||||
const normalizedTarget = path.normalize(targetPath);
|
||||
const normalizedRoot = path.normalize(projectRoot);
|
||||
if (
|
||||
!normalizedTarget.startsWith(normalizedRoot + path.sep) &&
|
||||
normalizedTarget !== normalizedRoot
|
||||
) {
|
||||
if (!normalizedTarget.startsWith(normalizedRoot + path.sep) && normalizedTarget !== normalizedRoot) {
|
||||
this.log.warn("fileWriteRequest path traversal attempt", {
|
||||
targetPath: normalizedTarget,
|
||||
projectRoot: normalizedRoot,
|
||||
@ -999,8 +942,7 @@ class GadgetDrone extends GadgetProcess {
|
||||
|
||||
cb(true, { success: true });
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.log.error("failed to write file", {
|
||||
path: args.path,
|
||||
targetPath,
|
||||
@ -1045,11 +987,7 @@ class GadgetDrone extends GadgetProcess {
|
||||
const fileTreeEntry: FileTreeEntry = {
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
type: entry.isSymbolicLink()
|
||||
? "symlink"
|
||||
: entry.isDirectory()
|
||||
? "directory"
|
||||
: "file",
|
||||
type: entry.isSymbolicLink() ? "symlink" : entry.isDirectory() ? "directory" : "file",
|
||||
size: stat.size,
|
||||
modified: stat.mtime.toISOString(),
|
||||
isHidden: entry.name.startsWith("."),
|
||||
@ -1064,9 +1002,7 @@ class GadgetDrone extends GadgetProcess {
|
||||
return results;
|
||||
}
|
||||
|
||||
async onAbortWorkOrder(
|
||||
cb: (success: boolean, message?: string) => void,
|
||||
): Promise<void> {
|
||||
async onAbortWorkOrder(cb: (success: boolean, message?: string) => void): Promise<void> {
|
||||
this.log.info("abortWorkOrder received from platform", {
|
||||
registrationId: this.registration?._id,
|
||||
isProcessing: this.isProcessingWorkOrder,
|
||||
@ -1076,39 +1012,6 @@ class GadgetDrone extends GadgetProcess {
|
||||
cb(aborted, aborted ? "Abort signaled" : "No active work order to abort");
|
||||
}
|
||||
|
||||
async onRequestProcessStats(
|
||||
cb: (
|
||||
success: boolean,
|
||||
data?: { processes?: SubProcessStat[]; message?: string },
|
||||
) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const processes = SubProcessService.ps().map((sp) => {
|
||||
const summary = SubProcessService.summarize(sp);
|
||||
const hasExited = sp.process.exitCode !== null || sp.process.killed;
|
||||
const stat: SubProcessStat = {
|
||||
pid: summary.pid,
|
||||
command: sp.process.spawnargs[0] ?? "unknown",
|
||||
args: sp.process.spawnargs.slice(1),
|
||||
projectId: summary.project._id,
|
||||
projectSlug: summary.project.slug,
|
||||
projectName: summary.project.name,
|
||||
status: hasExited ? "stopped" : "running",
|
||||
createdAt: summary.createdAt.toISOString(),
|
||||
updatedAt: summary.updatedAt.toISOString(),
|
||||
stdoutFileName: summary.stdoutFileName,
|
||||
stderrFileName: summary.stderrFileName,
|
||||
};
|
||||
return stat;
|
||||
});
|
||||
|
||||
cb(true, { processes });
|
||||
} catch (error) {
|
||||
this.log.error("failed to collect process stats", { error });
|
||||
cb(false, { message: "Failed to collect process stats" });
|
||||
}
|
||||
}
|
||||
|
||||
async onRequestTermination(cb: (success: boolean) => void): Promise<void> {
|
||||
this.log.info("requestTermination received from platform", {
|
||||
registrationId: this.registration?._id,
|
||||
|
||||
@ -122,274 +122,6 @@ describe("AgentService", () => {
|
||||
expect(messages[2]?.toolCallId).toBe("call-1");
|
||||
});
|
||||
|
||||
it("uses subagent.response instead of full JSON for subagent tool calls in context replay", () => {
|
||||
const user = {
|
||||
_id: "user-1",
|
||||
email: "user@example.com",
|
||||
email_lc: "user@example.com",
|
||||
displayName: "User",
|
||||
flags: {
|
||||
isEmailVerified: true,
|
||||
isAdmin: false,
|
||||
isTest: true,
|
||||
isBanned: false,
|
||||
},
|
||||
};
|
||||
const session = {
|
||||
_id: "session-1",
|
||||
createdAt: new Date(),
|
||||
user,
|
||||
project: "project-1",
|
||||
name: "Test Session",
|
||||
mode: ChatSessionMode.Build,
|
||||
provider: "provider-1",
|
||||
selectedModel: "model",
|
||||
stats: {
|
||||
turnCount: 0,
|
||||
toolCallCount: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
},
|
||||
pins: [],
|
||||
};
|
||||
|
||||
const fullSubagentResult = JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
prompt: "Find the main entry point",
|
||||
thinking: "Let me check package.json first, then look for index files...",
|
||||
response: "The main entry point is src/index.ts as specified in package.json.",
|
||||
toolCalls: [
|
||||
{
|
||||
callId: "sa-call-1",
|
||||
name: "file_read",
|
||||
parameters: '{"path":"package.json"}',
|
||||
response: "PATH: package.json\n---\n{\"main\": \"src/index.ts\"}",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
toolCallCount: 1,
|
||||
inputTokens: 150,
|
||||
thinkingTokenCount: 80,
|
||||
responseTokens: 20,
|
||||
durationMs: 3500,
|
||||
durationLabel: "00:03",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const workOrder: IAgentWorkOrder = {
|
||||
createdAt: new Date(),
|
||||
turn: {
|
||||
_id: "turn-current",
|
||||
createdAt: new Date(),
|
||||
user,
|
||||
project: "project-1",
|
||||
session,
|
||||
provider: "provider-1",
|
||||
llm: "model",
|
||||
mode: ChatSessionMode.Build,
|
||||
status: ChatTurnStatus.Processing,
|
||||
prompts: { user: "continue" },
|
||||
blocks: [],
|
||||
toolCalls: [],
|
||||
subagents: [],
|
||||
stats: {
|
||||
toolCallCount: 0,
|
||||
inputTokens: 0,
|
||||
thinkingTokenCount: 0,
|
||||
responseTokens: 0,
|
||||
durationMs: 0,
|
||||
durationLabel: "pending",
|
||||
},
|
||||
},
|
||||
context: [
|
||||
{
|
||||
_id: "turn-sa-1",
|
||||
createdAt: new Date(),
|
||||
user,
|
||||
project: "project-1",
|
||||
session,
|
||||
provider: "provider-1",
|
||||
llm: "model",
|
||||
mode: ChatSessionMode.Build,
|
||||
status: ChatTurnStatus.Finished,
|
||||
prompts: { user: "Find the main entry point" },
|
||||
blocks: [
|
||||
{
|
||||
mode: "responding",
|
||||
createdAt: new Date(),
|
||||
content: "I'll spawn a subagent to find that.",
|
||||
},
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
callId: "sa-call-master",
|
||||
name: "subagent",
|
||||
parameters: '{"agent_type":"explore","prompt":"Find the main entry point"}',
|
||||
response: fullSubagentResult,
|
||||
subagent: {
|
||||
prompt: "Find the main entry point",
|
||||
thinking: "Let me check package.json first...",
|
||||
response: "The main entry point is src/index.ts as specified in package.json.",
|
||||
toolCalls: [
|
||||
{
|
||||
callId: "sa-call-1",
|
||||
name: "file_read",
|
||||
parameters: '{"path":"package.json"}',
|
||||
response: "PATH: package.json\n---\n{\"main\": \"src/index.ts\"}",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
toolCallCount: 1,
|
||||
inputTokens: 150,
|
||||
thinkingTokenCount: 80,
|
||||
responseTokens: 20,
|
||||
durationMs: 3500,
|
||||
durationLabel: "00:03",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
subagents: [],
|
||||
stats: {
|
||||
toolCallCount: 1,
|
||||
inputTokens: 0,
|
||||
thinkingTokenCount: 0,
|
||||
responseTokens: 0,
|
||||
durationMs: 0,
|
||||
durationLabel: "done",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = service.buildSessionContext(workOrder);
|
||||
|
||||
expect(messages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"tool",
|
||||
]);
|
||||
// The tool response should be ONLY the subagent's response text,
|
||||
// not the full JSON blob with thinking, toolCalls, and stats.
|
||||
const toolMessage = messages[2];
|
||||
expect(toolMessage?.role).toBe("tool");
|
||||
expect(toolMessage?.toolCallId).toBe("sa-call-master");
|
||||
expect(toolMessage?.toolName).toBe("subagent");
|
||||
expect(toolMessage?.content).toBe(
|
||||
"The main entry point is src/index.ts as specified in package.json.",
|
||||
);
|
||||
// Explicitly verify the full JSON is NOT in context
|
||||
expect(toolMessage?.content).not.toContain('"success":true');
|
||||
expect(toolMessage?.content).not.toContain('"toolCalls"');
|
||||
expect(toolMessage?.content).not.toContain('"thinking"');
|
||||
});
|
||||
|
||||
it("falls back to toolCall.response for subagent calls without parsed subagent data", () => {
|
||||
const user = {
|
||||
_id: "user-1",
|
||||
email: "user@example.com",
|
||||
email_lc: "user@example.com",
|
||||
displayName: "User",
|
||||
flags: {
|
||||
isEmailVerified: true,
|
||||
isAdmin: false,
|
||||
isTest: true,
|
||||
isBanned: false,
|
||||
},
|
||||
};
|
||||
const session = {
|
||||
_id: "session-1",
|
||||
createdAt: new Date(),
|
||||
user,
|
||||
project: "project-1",
|
||||
name: "Test Session",
|
||||
mode: ChatSessionMode.Build,
|
||||
provider: "provider-1",
|
||||
selectedModel: "model",
|
||||
stats: {
|
||||
turnCount: 0,
|
||||
toolCallCount: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
},
|
||||
pins: [],
|
||||
};
|
||||
const rawResponse = "Subagent completed the task successfully.";
|
||||
|
||||
const workOrder: IAgentWorkOrder = {
|
||||
createdAt: new Date(),
|
||||
turn: {
|
||||
_id: "turn-current",
|
||||
createdAt: new Date(),
|
||||
user,
|
||||
project: "project-1",
|
||||
session,
|
||||
provider: "provider-1",
|
||||
llm: "model",
|
||||
mode: ChatSessionMode.Build,
|
||||
status: ChatTurnStatus.Processing,
|
||||
prompts: { user: "continue" },
|
||||
blocks: [],
|
||||
toolCalls: [],
|
||||
subagents: [],
|
||||
stats: {
|
||||
toolCallCount: 0,
|
||||
inputTokens: 0,
|
||||
thinkingTokenCount: 0,
|
||||
responseTokens: 0,
|
||||
durationMs: 0,
|
||||
durationLabel: "pending",
|
||||
},
|
||||
},
|
||||
context: [
|
||||
{
|
||||
_id: "turn-fallback",
|
||||
createdAt: new Date(),
|
||||
user,
|
||||
project: "project-1",
|
||||
session,
|
||||
provider: "provider-1",
|
||||
llm: "model",
|
||||
mode: ChatSessionMode.Build,
|
||||
status: ChatTurnStatus.Finished,
|
||||
prompts: { user: "Do a task" },
|
||||
blocks: [
|
||||
{
|
||||
mode: "responding",
|
||||
createdAt: new Date(),
|
||||
content: "Spawning a subagent.",
|
||||
},
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
callId: "call-fallback",
|
||||
name: "subagent",
|
||||
parameters: '{"agent_type":"general","prompt":"Do a task"}',
|
||||
response: rawResponse,
|
||||
// subagent field is intentionally omitted — simulates
|
||||
// historical data from before the fix was deployed
|
||||
},
|
||||
],
|
||||
subagents: [],
|
||||
stats: {
|
||||
toolCallCount: 1,
|
||||
inputTokens: 0,
|
||||
thinkingTokenCount: 0,
|
||||
responseTokens: 0,
|
||||
durationMs: 0,
|
||||
durationLabel: "done",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = service.buildSessionContext(workOrder);
|
||||
const toolMessage = messages[2];
|
||||
expect(toolMessage?.content).toBe(rawResponse);
|
||||
});
|
||||
|
||||
it("does not expose mutating file tools in plan mode", () => {
|
||||
const toolNames = service.getToolNamesForMode(ChatSessionMode.Plan);
|
||||
|
||||
@ -412,20 +144,10 @@ describe("AgentService", () => {
|
||||
expect(toolNames).toContain("file_write");
|
||||
expect(toolNames).toContain("file_edit");
|
||||
|
||||
// Build mode also has .gadget plan tools (available in all modes)
|
||||
expect(toolNames).toContain("plan_file_read");
|
||||
expect(toolNames).toContain("plan_file_write");
|
||||
expect(toolNames).toContain("plan_file_edit");
|
||||
expect(toolNames).toContain("plan_list");
|
||||
});
|
||||
|
||||
it("exposes plan tools in all modes", () => {
|
||||
for (const mode of [ChatSessionMode.Build, ChatSessionMode.Test, ChatSessionMode.Ship, ChatSessionMode.Develop]) {
|
||||
const toolNames = service.getToolNamesForMode(mode);
|
||||
expect(toolNames).toContain("plan_file_read");
|
||||
expect(toolNames).toContain("plan_file_write");
|
||||
expect(toolNames).toContain("plan_file_edit");
|
||||
expect(toolNames).toContain("plan_list");
|
||||
}
|
||||
// Build mode should not have Plan-only .gadget tools
|
||||
expect(toolNames).not.toContain("plan_file_read");
|
||||
expect(toolNames).not.toContain("plan_file_write");
|
||||
expect(toolNames).not.toContain("plan_file_edit");
|
||||
expect(toolNames).not.toContain("plan_list");
|
||||
});
|
||||
});
|
||||
|
||||
@ -51,9 +51,6 @@ import {
|
||||
PlanListTool,
|
||||
ShellExecTool,
|
||||
SubagentTool,
|
||||
SubprocessTool,
|
||||
ListSkillsTool,
|
||||
ReadSkillTool,
|
||||
type DroneToolboxEnvironment,
|
||||
} from "../tools/index.ts";
|
||||
|
||||
@ -120,23 +117,18 @@ class AgentService extends GadgetService {
|
||||
this.toolbox.register(new FileWriteTool(this.toolbox), writeModes);
|
||||
this.toolbox.register(new FileEditTool(this.toolbox), writeModes);
|
||||
this.toolbox.register(new ShellExecTool(this.toolbox), writeModes);
|
||||
this.toolbox.register(new SubprocessTool(this.toolbox), writeModes);
|
||||
|
||||
// Plan tools — Gadget's .gadget directory: available in all modes
|
||||
this.toolbox.register(new PlanFileReadTool(this.toolbox), readOnlyModes);
|
||||
this.toolbox.register(new PlanFileWriteTool(this.toolbox), readOnlyModes);
|
||||
this.toolbox.register(new PlanFileEditTool(this.toolbox), readOnlyModes);
|
||||
this.toolbox.register(new PlanListTool(this.toolbox), readOnlyModes);
|
||||
// Plan tools — Gadget's own .gadget directory: only available in Plan mode
|
||||
this.toolbox.register(new PlanFileReadTool(this.toolbox), [ChatSessionMode.Plan]);
|
||||
this.toolbox.register(new PlanFileWriteTool(this.toolbox), [ChatSessionMode.Plan]);
|
||||
this.toolbox.register(new PlanFileEditTool(this.toolbox), [ChatSessionMode.Plan]);
|
||||
this.toolbox.register(new PlanListTool(this.toolbox), [ChatSessionMode.Plan]);
|
||||
|
||||
// Chat tools — subagent spawning: available in all modes
|
||||
const subagentTool = new SubagentTool(this.toolbox);
|
||||
subagentTool.setSpawner((agentType: string, prompt: string) => this.spawnSubagent(agentType, prompt));
|
||||
subagentTool.setSpawner((agentType, prompt) => this.spawnSubagent(agentType, prompt));
|
||||
this.toolbox.register(subagentTool, readOnlyModes);
|
||||
|
||||
// Project tools — skill discovery: available in all modes
|
||||
this.toolbox.register(new ListSkillsTool(this.toolbox), readOnlyModes);
|
||||
this.toolbox.register(new ReadSkillTool(this.toolbox), readOnlyModes);
|
||||
|
||||
this.log.info("started");
|
||||
}
|
||||
|
||||
@ -224,7 +216,7 @@ class AgentService extends GadgetService {
|
||||
try {
|
||||
response = await AiService.chat(
|
||||
turn.provider,
|
||||
this.buildDroneModelConfig(turn.provider, turn.llm, currentReasoning, turn.numCtx),
|
||||
this.buildDroneModelConfig(turn.provider, turn.llm, currentReasoning),
|
||||
chatOptions,
|
||||
this.makeStreamHandler(socket),
|
||||
);
|
||||
@ -303,29 +295,10 @@ class AgentService extends GadgetService {
|
||||
|
||||
this.currentToolCallId = null;
|
||||
|
||||
// For subagent calls, extract only the response text for the
|
||||
// Master Agent's context. The full JSON (thinking, toolCalls,
|
||||
// stats) is preserved in the DB for auditing and the UI, but it
|
||||
// should never enter the Master Agent's context window.
|
||||
let toolResponseContent = result;
|
||||
if (toolCall.function.name === "subagent") {
|
||||
try {
|
||||
const parsed = JSON.parse(result);
|
||||
if (parsed.success && parsed.data?.response) {
|
||||
toolResponseContent = parsed.data.response;
|
||||
}
|
||||
} catch (parseError) {
|
||||
this.log.warn("failed to parse subagent result for context", {
|
||||
error: parseError instanceof Error ? parseError.message : String(parseError),
|
||||
resultLength: result.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
createdAt: turn.createdAt,
|
||||
role: "tool",
|
||||
content: toolResponseContent,
|
||||
content: result,
|
||||
toolCallId: toolCall.callId,
|
||||
toolName: toolCall.function.name,
|
||||
});
|
||||
@ -362,7 +335,6 @@ class AgentService extends GadgetService {
|
||||
abortCurrentWorkOrder(): boolean {
|
||||
if (!this.abortController) return false;
|
||||
this.abortController.abort();
|
||||
AiService.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -434,19 +406,10 @@ class AgentService extends GadgetService {
|
||||
|
||||
if (turn.toolCalls?.length > 0) {
|
||||
for (const toolCall of turn.toolCalls) {
|
||||
// For subagent calls, use the already-parsed subagent.response
|
||||
// field instead of the full JSON stored in toolCall.response.
|
||||
// This keeps the Master Agent's context window clean — only the
|
||||
// subagent's final answer is relevant to the Master Agent.
|
||||
let toolResponseContent = toolCall.response;
|
||||
if (toolCall.name === "subagent" && toolCall.subagent?.response) {
|
||||
toolResponseContent = toolCall.subagent.response;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
createdAt: turn.createdAt,
|
||||
role: "tool",
|
||||
content: toolResponseContent,
|
||||
content: toolCall.response,
|
||||
toolCallId: toolCall.callId,
|
||||
toolName: toolCall.name,
|
||||
});
|
||||
@ -508,7 +471,6 @@ class AgentService extends GadgetService {
|
||||
provider: IAiProvider | GadgetId,
|
||||
modelId: string,
|
||||
reasoning: boolean | "low" | "medium" | "high",
|
||||
numCtxOverride?: number,
|
||||
): Omit<IDroneModelConfig, "provider"> {
|
||||
const settings = this.getModelSettings(provider, modelId);
|
||||
return {
|
||||
@ -519,7 +481,7 @@ class AgentService extends GadgetService {
|
||||
topP: settings?.topP ?? 0.9,
|
||||
topK: settings?.topK ?? 40,
|
||||
numPredict: settings?.numPredict ?? -1, // -1 = unlimited (Ollama)
|
||||
numCtx: numCtxOverride ?? settings?.numCtx ?? 131072, // session override > model settings > 128k fallback
|
||||
numCtx: settings?.numCtx ?? 131072, // 128k fallback
|
||||
maxCompletionTokens: settings?.maxCompletionTokens ?? 16384, // 16k fallback
|
||||
},
|
||||
};
|
||||
@ -582,8 +544,6 @@ class AgentService extends GadgetService {
|
||||
projectDir: WorkspaceService.getProjectDirectory(project.slug),
|
||||
cacheDir,
|
||||
});
|
||||
|
||||
this.toolbox.updateProjectContext(project, turn.mode);
|
||||
}
|
||||
|
||||
async spawnSubagent(
|
||||
@ -663,7 +623,7 @@ class AgentService extends GadgetService {
|
||||
|
||||
const response = await AiService.chat(
|
||||
provider,
|
||||
this.buildDroneModelConfig(provider, turn.llm, false, turn.numCtx),
|
||||
this.buildDroneModelConfig(provider, turn.llm, false),
|
||||
chatOptions,
|
||||
streamHandler,
|
||||
);
|
||||
|
||||
@ -18,7 +18,6 @@ const aiEnv: IAiEnvironment = {
|
||||
import { IAiProvider as DbAiProvider, GadgetId, type IDroneModelConfig } from "@gadget/api";
|
||||
import { GadgetService } from "../lib/service.js";
|
||||
import {
|
||||
AiApi,
|
||||
type IAiChatOptions,
|
||||
type IAiChatResponse,
|
||||
type IAiGenerateOptions,
|
||||
@ -48,8 +47,6 @@ class AiService extends GadgetService {
|
||||
return "svc:ai";
|
||||
}
|
||||
|
||||
private activeApi: AiApi | null = null;
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.log.info("started");
|
||||
}
|
||||
@ -106,11 +103,7 @@ class AiService extends GadgetService {
|
||||
});
|
||||
const api = this.getApi(config);
|
||||
const modelConfig: IAiModelConfig = { ...model, provider: config };
|
||||
try {
|
||||
return await api.generate(modelConfig, options, streamCallback);
|
||||
} finally {
|
||||
this.activeApi = null;
|
||||
}
|
||||
return api.generate(modelConfig, options, streamCallback);
|
||||
}
|
||||
|
||||
async chat(
|
||||
@ -128,23 +121,11 @@ class AiService extends GadgetService {
|
||||
});
|
||||
const api = this.getApi(config);
|
||||
const modelConfig: IAiModelConfig = { ...model, provider: config };
|
||||
try {
|
||||
return await api.chat(modelConfig, options, streamCallback);
|
||||
} finally {
|
||||
this.activeApi = null;
|
||||
}
|
||||
return await api.chat(modelConfig, options, streamCallback);
|
||||
}
|
||||
|
||||
getApi(provider: AiProviderConfig) {
|
||||
const api = createAiApi(aiEnv, provider, this.log);
|
||||
this.activeApi = api;
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Forcefully abort any in-progress request on the active API instance. */
|
||||
abort(): void {
|
||||
this.activeApi?.abort();
|
||||
this.activeApi = null;
|
||||
return createAiApi(aiEnv, provider, this.log);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IProject } from "@gadget/api";
|
||||
import SubProcessService, { type SubProcess } from "./subprocess.ts";
|
||||
|
||||
vi.mock("./workspace.js", () => ({
|
||||
default: {
|
||||
gadgetDir: "/tmp/.gadget",
|
||||
getProjectDirectory: vi.fn((slug: string) => process.cwd()),
|
||||
},
|
||||
}));
|
||||
|
||||
function makeProject(): IProject {
|
||||
return {
|
||||
_id: "proj-test",
|
||||
slug: "test-project",
|
||||
name: "Test Project",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: "system",
|
||||
} as unknown as IProject;
|
||||
}
|
||||
|
||||
const NODE = process.execPath;
|
||||
|
||||
describe("SubProcessService", () => {
|
||||
beforeEach(async () => {
|
||||
await SubProcessService.start();
|
||||
});
|
||||
|
||||
it("starts and stops cleanly", async () => {
|
||||
await SubProcessService.stop();
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("creates a subprocess and tracks it in ps", async () => {
|
||||
const project = makeProject();
|
||||
const sp = await SubProcessService.create(project, NODE, ["-e", "process.exit(0)"]);
|
||||
|
||||
expect(sp.pid).toBeGreaterThan(0);
|
||||
expect(sp.project.slug).toBe("test-project");
|
||||
expect(sp.stdoutFileName).toContain("subproc-");
|
||||
expect(sp.stdoutFileName).toContain(".stdout.log");
|
||||
expect(sp.stderrFileName).toContain(".stderr.log");
|
||||
|
||||
const list = SubProcessService.ps();
|
||||
expect(list.some((p) => p.pid === sp.pid)).toBe(true);
|
||||
|
||||
await SubProcessService.killPid(sp.pid);
|
||||
});
|
||||
|
||||
it("create throws if workspace is not initialized", async () => {
|
||||
const wsModule = await import("./workspace.js");
|
||||
const ws = wsModule.default as { gadgetDir: string | undefined };
|
||||
const original = ws.gadgetDir;
|
||||
ws.gadgetDir = undefined;
|
||||
|
||||
const project = makeProject();
|
||||
await expect(
|
||||
SubProcessService.create(project, NODE, ["-e", "process.exit(0)"]),
|
||||
).rejects.toThrow("Gadget workspace directory not initialized");
|
||||
|
||||
ws.gadgetDir = original;
|
||||
});
|
||||
|
||||
it("ps returns empty array when no processes exist", () => {
|
||||
const list = SubProcessService.ps();
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
expect(list.length).toBe(0);
|
||||
});
|
||||
|
||||
it("killPid terminates a running subprocess", async () => {
|
||||
const project = makeProject();
|
||||
const sp = await SubProcessService.create(project, NODE, ["-e", "setTimeout(() => {}, 60000)"]);
|
||||
|
||||
expect(SubProcessService.ps().length).toBe(1);
|
||||
|
||||
const killed = await SubProcessService.killPid(sp.pid);
|
||||
expect(killed).toBe(true);
|
||||
|
||||
expect(SubProcessService.ps().length).toBe(0);
|
||||
});
|
||||
|
||||
it("killPid throws for unknown pid", async () => {
|
||||
await expect(SubProcessService.killPid(99999)).rejects.toThrow("process not found");
|
||||
});
|
||||
|
||||
it("killAll terminates all subprocesses", async () => {
|
||||
const project = makeProject();
|
||||
await SubProcessService.create(project, NODE, ["-e", "setTimeout(() => {}, 60000)"]);
|
||||
await SubProcessService.create(project, NODE, ["-e", "setTimeout(() => {}, 60000)"]);
|
||||
|
||||
expect(SubProcessService.ps().length).toBe(2);
|
||||
|
||||
await SubProcessService.killAll();
|
||||
|
||||
expect(SubProcessService.ps().length).toBe(0);
|
||||
});
|
||||
|
||||
it("haltPid stops a process but preserves log files", async () => {
|
||||
const project = makeProject();
|
||||
const sp = await SubProcessService.create(project, NODE, [
|
||||
"-e",
|
||||
"setInterval(() => console.log('tick'), 1000)",
|
||||
]);
|
||||
|
||||
expect(SubProcessService.ps().length).toBe(1);
|
||||
|
||||
const halted = await SubProcessService.haltPid(sp.pid);
|
||||
expect(halted).toBe(true);
|
||||
|
||||
expect(SubProcessService.ps().length).toBe(0);
|
||||
});
|
||||
|
||||
it("haltPid throws for unknown pid", async () => {
|
||||
await expect(SubProcessService.haltPid(99999)).rejects.toThrow("process not found");
|
||||
});
|
||||
|
||||
it("getLog returns stdout content", async () => {
|
||||
const project = makeProject();
|
||||
const sp = await SubProcessService.create(project, NODE, [
|
||||
"-e",
|
||||
"console.log('hello from subprocess'); process.exit(0)",
|
||||
]);
|
||||
|
||||
await new Promise<void>((resolve) => sp.process.on("exit", () => resolve()));
|
||||
|
||||
const stdout = await SubProcessService.getLog(sp.pid, "stdout");
|
||||
expect(stdout).toContain("hello from subprocess");
|
||||
});
|
||||
|
||||
it("summarize returns serializable summary without ChildProcess/WriteStream", () => {
|
||||
const pid = 12345;
|
||||
const project = makeProject();
|
||||
const now = new Date();
|
||||
const sp: SubProcess = {
|
||||
project,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
process: null as any,
|
||||
pid,
|
||||
stdoutFileName: "/tmp/.gadget/subprocess-logs/subproc-12345.stdout.log",
|
||||
stderrFileName: "/tmp/.gadget/subprocess-logs/subproc-12345.stderr.log",
|
||||
};
|
||||
|
||||
const summary = SubProcessService.summarize(sp);
|
||||
expect(summary.pid).toBe(pid);
|
||||
expect(summary.project.slug).toBe("test-project");
|
||||
expect(summary.stdoutFileName).toBe(sp.stdoutFileName);
|
||||
expect(summary.stderrFileName).toBe(sp.stderrFileName);
|
||||
expect(summary.createdAt).toBe(now);
|
||||
expect(summary.updatedAt).toBe(now);
|
||||
});
|
||||
});
|
||||
@ -1,241 +0,0 @@
|
||||
// src/services/subprocess.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import assert from "node:assert";
|
||||
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { ChildProcess, spawn } from "node:child_process";
|
||||
|
||||
import { GadgetId, IProject } from "@gadget/api";
|
||||
|
||||
import WorkspaceService from "./workspace.js";
|
||||
|
||||
import { GadgetService } from "../lib/service.ts";
|
||||
|
||||
export interface SubProcess {
|
||||
project: IProject;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
process: ChildProcess;
|
||||
pid: number;
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
|
||||
export interface SubProcessSummary {
|
||||
pid: number;
|
||||
project: IProject;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
|
||||
type SubProcessMap = Map<number, SubProcess>;
|
||||
|
||||
const GRACEFUL_SHUTDOWN_MS = 5_000;
|
||||
|
||||
class SubProcessService extends GadgetService {
|
||||
private processMap: SubProcessMap = new Map<number, SubProcess>();
|
||||
|
||||
get name(): string {
|
||||
return "SubprocessService";
|
||||
}
|
||||
get slug(): string {
|
||||
return "svc:subprocess";
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.log.info("started");
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.killAll();
|
||||
this.log.info("stopped");
|
||||
}
|
||||
|
||||
async create(
|
||||
project: IProject,
|
||||
cmd: string,
|
||||
args?: string[],
|
||||
): Promise<SubProcess> {
|
||||
const NOW = new Date();
|
||||
assert(
|
||||
WorkspaceService.gadgetDir,
|
||||
"Gadget workspace directory not initialized",
|
||||
);
|
||||
|
||||
const logDir = path.join(WorkspaceService.gadgetDir, "subprocess-logs");
|
||||
await fs.promises.mkdir(logDir, { recursive: true });
|
||||
|
||||
const projectDir = WorkspaceService.getProjectDirectory(project.slug);
|
||||
const child = spawn(cmd, args, { cwd: projectDir });
|
||||
assert(child.pid, "subprocess did not define a process id");
|
||||
|
||||
const pid = child.pid;
|
||||
const stdoutFileName = path.join(logDir, `subproc-${pid}.stdout.log`);
|
||||
const stderrFileName = path.join(logDir, `subproc-${pid}.stderr.log`);
|
||||
|
||||
const stdoutFile = fs.createWriteStream(stdoutFileName, "utf-8");
|
||||
const stderrFile = fs.createWriteStream(stderrFileName, "utf-8");
|
||||
|
||||
const sp: SubProcess = {
|
||||
project,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
process: child,
|
||||
pid,
|
||||
stdoutFileName,
|
||||
stderrFileName,
|
||||
};
|
||||
|
||||
child.stdout.on("data", (data: any) => {
|
||||
sp.updatedAt = new Date();
|
||||
stdoutFile.write(data);
|
||||
});
|
||||
child.stderr.on("data", (data: any) => {
|
||||
sp.updatedAt = new Date();
|
||||
stderrFile.write(data);
|
||||
});
|
||||
|
||||
child.on("exit", () => {
|
||||
stdoutFile.close();
|
||||
stderrFile.close();
|
||||
});
|
||||
|
||||
this.processMap.set(pid, sp);
|
||||
|
||||
return sp;
|
||||
}
|
||||
|
||||
ps(): SubProcess[] {
|
||||
return Array.from(this.processMap.values());
|
||||
}
|
||||
|
||||
summarize(sp: SubProcess): SubProcessSummary {
|
||||
return {
|
||||
pid: sp.pid,
|
||||
project: sp.project,
|
||||
createdAt: sp.createdAt,
|
||||
updatedAt: sp.updatedAt,
|
||||
stdoutFileName: sp.stdoutFileName,
|
||||
stderrFileName: sp.stderrFileName,
|
||||
};
|
||||
}
|
||||
|
||||
async killPid(pid: number): Promise<boolean> {
|
||||
const sp = this.processMap.get(pid);
|
||||
if (!sp) {
|
||||
throw new Error("process not found");
|
||||
}
|
||||
return this.killSubProcess(sp);
|
||||
}
|
||||
|
||||
async killProject(projectId: GadgetId): Promise<void> {
|
||||
for (const sp of this.processMap.values()) {
|
||||
if (sp.project._id !== projectId) {
|
||||
continue;
|
||||
}
|
||||
await this.killSubProcess(sp);
|
||||
}
|
||||
}
|
||||
|
||||
async killSubProcess(sp: SubProcess): Promise<boolean> {
|
||||
await this.terminateProcess(sp);
|
||||
await this.removeLogFiles(sp);
|
||||
this.processMap.delete(sp.pid);
|
||||
this.log.info("subprocess killed", { pid: sp.pid });
|
||||
return true;
|
||||
}
|
||||
|
||||
async haltSubProcess(sp: SubProcess): Promise<boolean> {
|
||||
await this.terminateProcess(sp);
|
||||
this.processMap.delete(sp.pid);
|
||||
this.log.info("subprocess halted, logs retained", { pid: sp.pid });
|
||||
return true;
|
||||
}
|
||||
|
||||
async killAll(): Promise<boolean> {
|
||||
const entries = Array.from(this.processMap.entries());
|
||||
for (const [pid] of entries) {
|
||||
const sp = this.processMap.get(pid);
|
||||
if (sp) {
|
||||
await this.killSubProcess(sp);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async haltPid(pid: number): Promise<boolean> {
|
||||
const sp = this.processMap.get(pid);
|
||||
if (!sp) {
|
||||
throw new Error("process not found");
|
||||
}
|
||||
return this.haltSubProcess(sp);
|
||||
}
|
||||
|
||||
async getLog(
|
||||
pid: number,
|
||||
stream: "stdout" | "stderr",
|
||||
): Promise<string> {
|
||||
const sp = this.processMap.get(pid);
|
||||
if (!sp) {
|
||||
throw new Error("process not found");
|
||||
}
|
||||
|
||||
const filePath =
|
||||
stream === "stdout" ? sp.stdoutFileName : sp.stderrFileName;
|
||||
try {
|
||||
return await fs.promises.readFile(filePath, "utf-8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private async terminateProcess(sp: SubProcess): Promise<void> {
|
||||
const pid = sp.pid;
|
||||
this.log.info("terminating subprocess", { pid });
|
||||
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
sp.process.on("exit", () => resolve());
|
||||
});
|
||||
|
||||
sp.process.stdout?.removeAllListeners();
|
||||
sp.process.stderr?.removeAllListeners();
|
||||
|
||||
sp.process.kill("SIGINT");
|
||||
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("graceful shutdown timeout")), GRACEFUL_SHUTDOWN_MS),
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.race([exited, timeout]);
|
||||
} catch {
|
||||
this.log.warn("subprocess did not exit gracefully, sending SIGKILL", {
|
||||
pid,
|
||||
});
|
||||
sp.process.kill("SIGKILL");
|
||||
try {
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("SIGKILL timeout")), GRACEFUL_SHUTDOWN_MS),
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
this.log.warn("subprocess did not respond to SIGKILL", { pid });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async removeLogFiles(sp: SubProcess): Promise<void> {
|
||||
await fs.promises.rm(sp.stdoutFileName, { force: true });
|
||||
await fs.promises.rm(sp.stderrFileName, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new SubProcessService();
|
||||
@ -68,7 +68,7 @@ export interface WorkOrderCache {
|
||||
}
|
||||
|
||||
class WorkspaceService extends GadgetService {
|
||||
private _gadgetDir: string = "";
|
||||
private gadgetDir: string = "";
|
||||
private cacheDir: string = "";
|
||||
|
||||
private workspaceFile: string = "";
|
||||
@ -84,10 +84,6 @@ class WorkspaceService extends GadgetService {
|
||||
return "svc:workspace";
|
||||
}
|
||||
|
||||
get gadgetDir(): string | undefined {
|
||||
return this._gadgetDir || undefined;
|
||||
}
|
||||
|
||||
get workspaceData(): WorkspaceData | null {
|
||||
return this._workspaceData;
|
||||
}
|
||||
@ -118,12 +114,12 @@ class WorkspaceService extends GadgetService {
|
||||
* Validates or creates workspace.json with persistent identity.
|
||||
*/
|
||||
async initialize(workspaceDir: string): Promise<void> {
|
||||
this._gadgetDir = path.join(workspaceDir, ".gadget");
|
||||
this.cacheDir = path.join(this._gadgetDir, "cache");
|
||||
this.workspaceFile = path.join(this._gadgetDir, "workspace.json");
|
||||
this.gadgetDir = path.join(workspaceDir, ".gadget");
|
||||
this.cacheDir = path.join(this.gadgetDir, "cache");
|
||||
this.workspaceFile = path.join(this.gadgetDir, "workspace.json");
|
||||
|
||||
// Create directory structure
|
||||
await fs.promises.mkdir(this._gadgetDir, { recursive: true });
|
||||
await fs.promises.mkdir(this.gadgetDir, { recursive: true });
|
||||
await fs.promises.mkdir(this.cacheDir, { recursive: true });
|
||||
|
||||
// Load or create workspace data
|
||||
@ -315,7 +311,9 @@ class WorkspaceService extends GadgetService {
|
||||
*/
|
||||
markProjectDeployed(slug: string): void {
|
||||
if (!this._workspaceData) return;
|
||||
const project = this._workspaceData.projects.find((p) => p.slug === slug);
|
||||
const project = this._workspaceData.projects.find(
|
||||
(p) => p.slug === slug,
|
||||
);
|
||||
if (project) {
|
||||
const now = new Date().toISOString();
|
||||
project.clonedAt = now;
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// src/chat/index.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
// src/chat/subagent.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { formatError } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
|
||||
const VALID_AGENT_TYPES = ["explore", "general"] as const;
|
||||
type AgentType = (typeof VALID_AGENT_TYPES)[number];
|
||||
|
||||
export class SubagentTool extends GadgetTool {
|
||||
export class SubagentTool extends DroneTool {
|
||||
private _spawnSubagent: ((agentType: string, prompt: string) => Promise<string>) | null = null;
|
||||
|
||||
setSpawner(fn: typeof this._spawnSubagent): void {
|
||||
@ -1,27 +1,9 @@
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Re-export from @gadget/ai-toolbox
|
||||
export {
|
||||
AiToolbox,
|
||||
type GadgetToolboxEnvironment as DroneToolboxEnvironment,
|
||||
FileReadTool,
|
||||
FileWriteTool,
|
||||
FileEditTool,
|
||||
ShellExecTool,
|
||||
ListTool,
|
||||
GrepTool,
|
||||
GlobTool,
|
||||
GoogleSearchTool,
|
||||
FetchUrlTool,
|
||||
PlanFileReadTool,
|
||||
PlanFileWriteTool,
|
||||
PlanFileEditTool,
|
||||
PlanListTool,
|
||||
SubagentTool,
|
||||
ListSkillsTool,
|
||||
ReadSkillTool,
|
||||
} from "@gadget/ai-toolbox";
|
||||
|
||||
// Local tools (gadget-drone specific)
|
||||
export { SubprocessTool } from "./system/index.ts";
|
||||
export { AiToolbox, type DroneToolboxEnvironment } from "./toolbox.ts";
|
||||
export { DroneTool } from "./tool.ts";
|
||||
export * from "./chat/index.ts";
|
||||
export * from "./system/index.ts";
|
||||
export * from "./network/index.ts";
|
||||
export * from "./plan/index.ts";
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
// src/network/fetch-url.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import { asPositiveInteger, getCacheDir, toolError } from "../system/common.ts";
|
||||
import { WebFetcher } from "./web-fetcher.ts";
|
||||
|
||||
export class FetchUrlTool extends GadgetTool {
|
||||
export class FetchUrlTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "fetch_url";
|
||||
}
|
||||
@ -20,7 +19,7 @@ export class FetchUrlTool extends GadgetTool {
|
||||
type: "function",
|
||||
function: {
|
||||
name: this.name,
|
||||
description: "Fetch a URL, convert readable page content to line-numbered Markdown, cache it in the .gadget/cache directory, and return an optional line range like file_read.",
|
||||
description: "Fetch a URL, convert readable page content to line-numbered Markdown, cache it in the drone .gadget/cache directory, and return an optional line range like file_read.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@ -78,7 +77,7 @@ export class FetchUrlTool extends GadgetTool {
|
||||
if (!cacheDir) {
|
||||
return toolError({
|
||||
code: "OPERATION_NOT_ALLOWED",
|
||||
message: "No cache directory is configured for fetch_url.",
|
||||
message: "No drone cache directory is configured for fetch_url.",
|
||||
recoveryHint: "Run this tool during an active Agent work order after workspace initialization.",
|
||||
});
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
// src/network/index.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// src/network/search-google.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -6,7 +5,7 @@ import { google } from "googleapis";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { formatError } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
|
||||
export interface ISearchResult {
|
||||
title: string;
|
||||
@ -27,7 +26,7 @@ export interface ISearchOptions {
|
||||
start?: number;
|
||||
}
|
||||
|
||||
export class GoogleSearchTool extends GadgetTool {
|
||||
export class GoogleSearchTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "search_google";
|
||||
}
|
||||
@ -141,7 +140,7 @@ export class GoogleSearchTool extends GadgetTool {
|
||||
const engineId = this.toolbox.env.services?.google?.cse?.engineId;
|
||||
|
||||
if (!apiKey || !engineId) {
|
||||
throw new Error("Google CSE credentials not configured in environment");
|
||||
throw new Error("Google CSE credentials not configured in drone environment");
|
||||
}
|
||||
|
||||
const customSearch = google.customsearch({
|
||||
@ -1,4 +1,3 @@
|
||||
// src/network/web-fetcher.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -30,8 +29,15 @@ export class WebFetcher {
|
||||
hr: "---",
|
||||
});
|
||||
this.turndown.remove([
|
||||
"script", "style", "noscript", "nav", "footer", "header",
|
||||
"button", "input", "form",
|
||||
"script",
|
||||
"style",
|
||||
"noscript",
|
||||
"nav",
|
||||
"footer",
|
||||
"header",
|
||||
"button",
|
||||
"input",
|
||||
"form",
|
||||
]);
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// src/plan/common.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
// src/plan/edit.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import { toolError } from "../system/common.ts";
|
||||
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
||||
|
||||
export class PlanFileEditTool extends GadgetTool {
|
||||
export class PlanFileEditTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "plan_file_edit";
|
||||
}
|
||||
@ -23,7 +22,7 @@ export class PlanFileEditTool extends GadgetTool {
|
||||
function: {
|
||||
name: this.name,
|
||||
description:
|
||||
"Perform an exact search-and-replace edit on a file in Gadget's .gadget directory. Replaces the first occurrence only and returns changed-line context. Operates exclusively in the .gadget directory for updating plans, todos, and other knowledge. Does not affect project files.",
|
||||
"Perform an exact search-and-replace edit on a file in Gadget's .gadget directory. Replaces the first occurrence only and returns changed-line context. This Plan-mode tool works in Gadget's own directory (.gadget) for storing and updating plans, todos, and other knowledge. It does not affect project files.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@ -1,4 +1,3 @@
|
||||
// src/plan/index.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// src/plan/list.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -6,11 +5,11 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import { toolError } from "../system/common.ts";
|
||||
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
||||
|
||||
export class PlanListTool extends GadgetTool {
|
||||
export class PlanListTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "plan_list";
|
||||
}
|
||||
@ -24,7 +23,7 @@ export class PlanListTool extends GadgetTool {
|
||||
function: {
|
||||
name: this.name,
|
||||
description:
|
||||
"List contents of Gadget's .gadget directory. Operates exclusively in the .gadget directory for exploring stored plans, todos, and other knowledge. Does not affect project files.",
|
||||
"List contents of Gadget's .gadget directory. This Plan-mode tool works in Gadget's own directory (.gadget) for exploring stored plans, todos, and other knowledge. It does not affect project files.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@ -1,11 +1,10 @@
|
||||
// src/plan/read.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import {
|
||||
asPositiveInteger,
|
||||
formatNumberedLines,
|
||||
@ -14,7 +13,7 @@ import {
|
||||
} from "../system/common.ts";
|
||||
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
||||
|
||||
export class PlanFileReadTool extends GadgetTool {
|
||||
export class PlanFileReadTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "plan_file_read";
|
||||
}
|
||||
@ -28,7 +27,7 @@ export class PlanFileReadTool extends GadgetTool {
|
||||
function: {
|
||||
name: this.name,
|
||||
description:
|
||||
"Read a file from Gadget's .gadget directory with line numbers. Operates exclusively in the .gadget directory for storing and reading plans, todos, and other knowledge. Does not affect project files.",
|
||||
"Read a file from Gadget's .gadget directory with line numbers. This Plan-mode tool works in Gadget's own directory (.gadget) for storing and reading plans, todos, and other knowledge. It does not affect project files.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@ -1,4 +1,3 @@
|
||||
// src/plan/write.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -6,11 +5,11 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import { toolError } from "../system/common.ts";
|
||||
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
||||
|
||||
export class PlanFileWriteTool extends GadgetTool {
|
||||
export class PlanFileWriteTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "plan_file_write";
|
||||
}
|
||||
@ -24,7 +23,7 @@ export class PlanFileWriteTool extends GadgetTool {
|
||||
function: {
|
||||
name: this.name,
|
||||
description:
|
||||
"Create or overwrite a file in Gadget's .gadget directory. The .gadget directory is created automatically if it doesn't exist. Operates exclusively in the .gadget directory for storing plans, todos, and other knowledge. Does not affect project files.",
|
||||
"Create or overwrite a file in Gadget's .gadget directory. The .gadget directory is created automatically if it doesn't exist. This Plan-mode tool works in Gadget's own directory (.gadget) for storing plans, todos, and other knowledge. It does not affect project files.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@ -1,4 +1,3 @@
|
||||
// src/system/common.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -9,10 +8,30 @@ import { formatError, type IToolError } from "@gadget/ai";
|
||||
import type { AiToolbox } from "../toolbox.ts";
|
||||
|
||||
export const BINARY_EXTENSIONS = new Set([
|
||||
".o", ".obj", ".a", ".lib", ".so", ".dll", ".exe", ".bin",
|
||||
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp",
|
||||
".pdf", ".zip", ".tar", ".gz", ".bz2", ".7z", ".wasm",
|
||||
".pyc", ".class",
|
||||
".o",
|
||||
".obj",
|
||||
".a",
|
||||
".lib",
|
||||
".so",
|
||||
".dll",
|
||||
".exe",
|
||||
".bin",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".bmp",
|
||||
".ico",
|
||||
".webp",
|
||||
".pdf",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".bz2",
|
||||
".7z",
|
||||
".wasm",
|
||||
".pyc",
|
||||
".class",
|
||||
]);
|
||||
|
||||
export interface ResolvedProjectPath {
|
||||
@ -1,14 +1,13 @@
|
||||
// src/system/edit.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import { resolveProjectPath, toolError } from "./common.ts";
|
||||
|
||||
export class FileEditTool extends GadgetTool {
|
||||
export class FileEditTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "file_edit";
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
// src/system/glob.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -6,12 +5,12 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
|
||||
|
||||
const MAX_RESULTS = 1000;
|
||||
|
||||
export class GlobTool extends GadgetTool {
|
||||
export class GlobTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "glob";
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
// src/system/grep.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -6,16 +5,17 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
|
||||
|
||||
const MAX_MATCHES = 500;
|
||||
const MAX_FILE_SIZE = 1024 * 1024;
|
||||
|
||||
// TODO: Consider broadening this list or making it user-configurable
|
||||
// Currently limited to common source/doc extensions for performance.
|
||||
const SEARCH_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt"]);
|
||||
|
||||
export class GrepTool extends GadgetTool {
|
||||
export class GrepTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "grep";
|
||||
}
|
||||
@ -1,4 +1,10 @@
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
export { SubprocessTool } from "./subprocess.ts";
|
||||
export { FileReadTool } from "./read.ts";
|
||||
export { FileWriteTool } from "./write.ts";
|
||||
export { FileEditTool } from "./edit.ts";
|
||||
export { ShellExecTool } from "./shell.ts";
|
||||
export { ListTool } from "./list.ts";
|
||||
export { GrepTool } from "./grep.ts";
|
||||
export { GlobTool } from "./glob.ts";
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// src/system/list.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -6,12 +5,12 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
|
||||
|
||||
const MAX_ENTRIES = 1000;
|
||||
|
||||
export class ListTool extends GadgetTool {
|
||||
export class ListTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "list";
|
||||
}
|
||||
@ -1,11 +1,10 @@
|
||||
// src/system/read.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import {
|
||||
asPositiveInteger,
|
||||
formatNumberedLines,
|
||||
@ -14,7 +13,7 @@ import {
|
||||
toolError,
|
||||
} from "./common.ts";
|
||||
|
||||
export class FileReadTool extends GadgetTool {
|
||||
export class FileReadTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "file_read";
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
// src/system/shell.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -7,14 +6,14 @@ import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const DEFAULT_TIMEOUT = 30_000;
|
||||
const MAX_TIMEOUT = 120_000;
|
||||
|
||||
export class ShellExecTool extends GadgetTool {
|
||||
export class ShellExecTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "shell_exec";
|
||||
}
|
||||
@ -1,315 +0,0 @@
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { formatError } from "@gadget/ai";
|
||||
import { GadgetTool } from "@gadget/ai-toolbox";
|
||||
import SubProcessService from "../../services/subprocess.ts";
|
||||
|
||||
const VALID_CMDS = [
|
||||
"create",
|
||||
"list",
|
||||
"kill",
|
||||
"killAll",
|
||||
"halt",
|
||||
"log",
|
||||
] as const;
|
||||
|
||||
type SubprocessCmd = (typeof VALID_CMDS)[number];
|
||||
|
||||
export class SubprocessTool extends GadgetTool {
|
||||
get name(): string {
|
||||
return "subprocess";
|
||||
}
|
||||
|
||||
get category(): string {
|
||||
return "system";
|
||||
}
|
||||
|
||||
get definition(): IToolDefinition {
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name: this.name,
|
||||
description:
|
||||
"Manage child processes spawned by this agent. Use 'create' to spawn a new process, 'list' to see running processes, 'kill' to stop a process and remove its log files, 'halt' to stop a process while retaining its log files for later inspection, 'killAll' to stop all running processes, and 'log' to read stdout or stderr output from a running or halted process.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
cmd: {
|
||||
type: "string",
|
||||
enum: [...VALID_CMDS],
|
||||
description:
|
||||
"The subprocess command to execute. create: spawn a new process. list: show all managed processes. kill: terminate a process and remove its logs. halt: terminate a process but keep its logs. killAll: terminate all managed processes. log: read stdout or stderr output from a process.",
|
||||
},
|
||||
command: {
|
||||
type: "string",
|
||||
description:
|
||||
"The shell command to execute (required for cmd=create). Example: 'node', 'npm', 'python', './my-server'.",
|
||||
},
|
||||
args: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Command-line arguments for the process (optional, used with cmd=create). Example: ['run', 'dev'].",
|
||||
},
|
||||
pid: {
|
||||
type: "number",
|
||||
description:
|
||||
"Process ID of the managed subprocess (required for cmd=kill, cmd=halt, cmd=log).",
|
||||
},
|
||||
which: {
|
||||
type: "string",
|
||||
enum: ["stdout", "stderr"],
|
||||
description:
|
||||
"Which log stream to read (required for cmd=log). 'stdout' for standard output, 'stderr' for standard error.",
|
||||
},
|
||||
},
|
||||
required: ["cmd"],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
|
||||
const cmd = args.cmd as string | undefined;
|
||||
|
||||
if (!cmd) {
|
||||
return formatError({
|
||||
code: "MISSING_PARAMETER",
|
||||
message: "The 'cmd' parameter is required.",
|
||||
parameter: "cmd",
|
||||
expected: `One of: ${VALID_CMDS.join(", ")}`,
|
||||
recoveryHint: `Specify a valid command: ${VALID_CMDS.join(", ")}.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!VALID_CMDS.includes(cmd as SubprocessCmd)) {
|
||||
return formatError({
|
||||
code: "INVALID_PARAMETER",
|
||||
message: `Invalid cmd: '${cmd}'. Must be one of: ${VALID_CMDS.join(", ")}`,
|
||||
parameter: "cmd",
|
||||
expected: `One of: ${VALID_CMDS.join(", ")}`,
|
||||
recoveryHint: `Use one of: ${VALID_CMDS.join(", ")}.`,
|
||||
});
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case "create":
|
||||
return this.cmdCreate(args, logger);
|
||||
case "list":
|
||||
return this.cmdList(args, logger);
|
||||
case "kill":
|
||||
return this.cmdKill(args, logger);
|
||||
case "killAll":
|
||||
return this.cmdKillAll(args, logger);
|
||||
case "halt":
|
||||
return this.cmdHalt(args, logger);
|
||||
case "log":
|
||||
return this.cmdLog(args, logger);
|
||||
default:
|
||||
return formatError({
|
||||
code: "INVALID_PARAMETER",
|
||||
message: `Unknown subprocess command: '${cmd}'`,
|
||||
parameter: "cmd",
|
||||
expected: `One of: ${VALID_CMDS.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async cmdCreate(
|
||||
args: IToolArguments,
|
||||
logger: IAiLogger,
|
||||
): Promise<string> {
|
||||
const command = args.command as string | undefined;
|
||||
if (!command || typeof command !== "string" || command.trim().length === 0) {
|
||||
return formatError({
|
||||
code: "MISSING_PARAMETER",
|
||||
message: "The 'command' parameter is required for cmd=create.",
|
||||
parameter: "command",
|
||||
recoveryHint:
|
||||
"Provide the command to execute, e.g. 'node', 'npm', 'python'.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawArgs = args.args;
|
||||
const cmdArgs: string[] | undefined =
|
||||
Array.isArray(rawArgs)
|
||||
? rawArgs.map((a) => String(a))
|
||||
: undefined;
|
||||
|
||||
const project = this.toolbox.env.workspace?.projectDir
|
||||
? { _id: "workspace", slug: this.toolbox.env.workspace.projectDir.split("/").pop() || "project" }
|
||||
: { _id: "workspace", slug: "project" };
|
||||
|
||||
const projectObj = {
|
||||
_id: project._id,
|
||||
slug: project.slug,
|
||||
name: project.slug,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: "system",
|
||||
};
|
||||
|
||||
try {
|
||||
const sp = await SubProcessService.create(projectObj as any, command, cmdArgs);
|
||||
return [
|
||||
"SUBPROCESS CREATED",
|
||||
`pid: ${sp.pid}`,
|
||||
`stdout: ${sp.stdoutFileName}`,
|
||||
`stderr: ${sp.stderrFileName}`,
|
||||
].join("\n");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error("failed to create subprocess", {
|
||||
command,
|
||||
args: cmdArgs,
|
||||
error: message,
|
||||
});
|
||||
return formatError({
|
||||
code: "OPERATION_FAILED",
|
||||
message: `Failed to spawn subprocess: ${message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async cmdList(
|
||||
_args: IToolArguments,
|
||||
_logger: IAiLogger,
|
||||
): Promise<string> {
|
||||
const processes = SubProcessService.ps();
|
||||
if (processes.length === 0) {
|
||||
return "SUBPROCESS LIST\n(no running subprocesses)";
|
||||
}
|
||||
|
||||
const lines = ["SUBPROCESS LIST"];
|
||||
for (const sp of processes) {
|
||||
lines.push(
|
||||
`pid: ${sp.pid} | project: ${sp.project.slug} | created: ${sp.createdAt.toISOString()} | updated: ${sp.updatedAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
private async cmdKill(
|
||||
args: IToolArguments,
|
||||
logger: IAiLogger,
|
||||
): Promise<string> {
|
||||
const pid = args.pid as number | undefined;
|
||||
if (typeof pid !== "number" || !Number.isFinite(pid)) {
|
||||
return formatError({
|
||||
code: "MISSING_PARAMETER",
|
||||
message: "The 'pid' parameter is required for cmd=kill.",
|
||||
parameter: "pid",
|
||||
recoveryHint: "Provide the numeric process ID to kill.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await SubProcessService.killPid(pid);
|
||||
return `SUBPROCESS KILLED\npid: ${pid}`;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error("failed to kill subprocess", { pid, error: message });
|
||||
return formatError({
|
||||
code: "OPERATION_FAILED",
|
||||
message: `Failed to kill subprocess: ${message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async cmdKillAll(
|
||||
_args: IToolArguments,
|
||||
logger: IAiLogger,
|
||||
): Promise<string> {
|
||||
try {
|
||||
await SubProcessService.killAll();
|
||||
return "SUBPROCESS KILLALL\nAll managed subprocesses have been terminated and their logs removed.";
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error("failed to kill all subprocesses", { error: message });
|
||||
return formatError({
|
||||
code: "OPERATION_FAILED",
|
||||
message: `Failed to kill all subprocesses: ${message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async cmdHalt(
|
||||
args: IToolArguments,
|
||||
logger: IAiLogger,
|
||||
): Promise<string> {
|
||||
const pid = args.pid as number | undefined;
|
||||
if (typeof pid !== "number" || !Number.isFinite(pid)) {
|
||||
return formatError({
|
||||
code: "MISSING_PARAMETER",
|
||||
message: "The 'pid' parameter is required for cmd=halt.",
|
||||
parameter: "pid",
|
||||
recoveryHint: "Provide the numeric process ID to halt.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await SubProcessService.haltPid(pid);
|
||||
return [
|
||||
"SUBPROCESS HALTED",
|
||||
`pid: ${pid}`,
|
||||
"The process has been stopped. Log files are retained for inspection.",
|
||||
].join("\n");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error("failed to halt subprocess", { pid, error: message });
|
||||
return formatError({
|
||||
code: "OPERATION_FAILED",
|
||||
message: `Failed to halt subprocess: ${message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async cmdLog(
|
||||
args: IToolArguments,
|
||||
logger: IAiLogger,
|
||||
): Promise<string> {
|
||||
const pid = args.pid as number | undefined;
|
||||
if (typeof pid !== "number" || !Number.isFinite(pid)) {
|
||||
return formatError({
|
||||
code: "MISSING_PARAMETER",
|
||||
message: "The 'pid' parameter is required for cmd=log.",
|
||||
parameter: "pid",
|
||||
recoveryHint: "Provide the numeric process ID to read logs from.",
|
||||
});
|
||||
}
|
||||
|
||||
const which = args.which as string | undefined;
|
||||
if (!which || (which !== "stdout" && which !== "stderr")) {
|
||||
return formatError({
|
||||
code: "MISSING_PARAMETER",
|
||||
message: "The 'which' parameter is required for cmd=log.",
|
||||
parameter: "which",
|
||||
expected: "'stdout' or 'stderr'",
|
||||
recoveryHint: "Specify 'stdout' to read standard output or 'stderr' to read standard error.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await SubProcessService.getLog(pid, which);
|
||||
return [
|
||||
`SUBPROCESS LOG (${which})`,
|
||||
`pid: ${pid}`,
|
||||
"---",
|
||||
content || "(log is empty)",
|
||||
].join("\n");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error("failed to read subprocess log", {
|
||||
pid,
|
||||
which,
|
||||
error: message,
|
||||
});
|
||||
return formatError({
|
||||
code: "OPERATION_FAILED",
|
||||
message: `Failed to read subprocess log: ${message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
// src/system/write.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -6,10 +5,10 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { GadgetTool } from "../tool.ts";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import { resolveProjectPath, toolError } from "./common.ts";
|
||||
|
||||
export class FileWriteTool extends GadgetTool {
|
||||
export class FileWriteTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "file_write";
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
// src/tool.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
@ -10,7 +9,7 @@ import type {
|
||||
} from "@gadget/ai";
|
||||
import type { AiToolbox } from "./toolbox.ts";
|
||||
|
||||
export abstract class GadgetTool implements IAiTool {
|
||||
export abstract class DroneTool implements IAiTool {
|
||||
protected _toolbox: AiToolbox;
|
||||
|
||||
constructor(toolbox: AiToolbox) {
|
||||
@ -1,11 +1,9 @@
|
||||
// src/toolbox.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import type { IAiTool } from "@gadget/ai";
|
||||
import type { IProject, ChatSessionMode } from "@gadget/api";
|
||||
|
||||
export interface GadgetToolboxEnvironment {
|
||||
export interface DroneToolboxEnvironment {
|
||||
NODE_ENV: string;
|
||||
services?: {
|
||||
google?: {
|
||||
@ -20,35 +18,28 @@ export interface GadgetToolboxEnvironment {
|
||||
projectDir?: string;
|
||||
cacheDir?: string;
|
||||
};
|
||||
project?: IProject;
|
||||
chatSessionMode?: ChatSessionMode;
|
||||
}
|
||||
|
||||
export type ToolMap = Map<string, IAiTool>;
|
||||
export type ToolSet = Set<IAiTool>;
|
||||
|
||||
export class AiToolbox {
|
||||
private _env: GadgetToolboxEnvironment;
|
||||
private _env: DroneToolboxEnvironment;
|
||||
private tools: ToolMap = new Map<string, IAiTool>();
|
||||
private modeSets: Map<string, ToolSet> = new Map<string, Set<IAiTool>>();
|
||||
|
||||
constructor(env: GadgetToolboxEnvironment) {
|
||||
constructor(env: DroneToolboxEnvironment) {
|
||||
this._env = env;
|
||||
}
|
||||
|
||||
get env(): GadgetToolboxEnvironment {
|
||||
get env(): DroneToolboxEnvironment {
|
||||
return this._env;
|
||||
}
|
||||
|
||||
updateWorkspace(workspace: NonNullable<GadgetToolboxEnvironment["workspace"]>): void {
|
||||
updateWorkspace(workspace: NonNullable<DroneToolboxEnvironment["workspace"]>): void {
|
||||
this._env.workspace = workspace;
|
||||
}
|
||||
|
||||
updateProjectContext(project: IProject, mode: ChatSessionMode): void {
|
||||
this._env.project = project;
|
||||
this._env.chatSessionMode = mode;
|
||||
}
|
||||
|
||||
register(tool: IAiTool, modes?: string[]): void {
|
||||
if (this.tools.has(tool.name)) {
|
||||
throw new Error(`tool already registered: ${tool.name}`);
|
||||
@ -1,10 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["**/node_modules/**", "**/dist/**"],
|
||||
},
|
||||
});
|
||||
1
gadget-tasks/.gitignore
vendored
1
gadget-tasks/.gitignore
vendored
@ -1 +0,0 @@
|
||||
dist
|
||||
@ -1,13 +0,0 @@
|
||||
timezone: America/New_York
|
||||
platform:
|
||||
baseUrl: https://code-dev.g4dge7.com:5174
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
concurrency: 1
|
||||
logging:
|
||||
console:
|
||||
enabled: true
|
||||
file:
|
||||
enabled: true
|
||||
path: ~/logs/gadget-tasks
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "gadget-tasks",
|
||||
"version": "1.0.0",
|
||||
"description": "Gadget Code scheduled task worker process",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"gadget-tasks": "./dist/gadget-tasks.js"
|
||||
},
|
||||
"main": "./dist/gadget-tasks.js",
|
||||
"scripts": {
|
||||
"dev": "tsx src/gadget-tasks.ts",
|
||||
"dev:watch": "tsx watch src/gadget-tasks.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/gadget-tasks.js"
|
||||
},
|
||||
"keywords": [
|
||||
"gadget",
|
||||
"tasks",
|
||||
"cron",
|
||||
"scheduler",
|
||||
"worker"
|
||||
],
|
||||
"author": "Rob Colbert",
|
||||
"license": "Apache-2.0",
|
||||
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
|
||||
"dependencies": {
|
||||
"@gadget/api": "workspace:*",
|
||||
"@gadget/config": "workspace:*",
|
||||
"@inquirer/prompts": "^8.4.2",
|
||||
"cron": "^4.3.1",
|
||||
"ioredis": "^5.6.0",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
// config/env.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import path, { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadGadgetTasksConfig, resolvePath } from "@gadget/config";
|
||||
import type PackageJson from "../../package.json";
|
||||
import { GadgetLog, GadgetLogFile } from "@gadget/api";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// INSTALL_DIR: where the package is installed (for loading assets, etc.)
|
||||
export const INSTALL_DIR = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// Load YAML configuration
|
||||
const yamlConfig = loadGadgetTasksConfig();
|
||||
|
||||
// Validate required fields
|
||||
if (!yamlConfig.platform?.baseUrl) {
|
||||
throw new Error(
|
||||
"Configuration error: platform.baseUrl is required in gadget-tasks.yaml\n" +
|
||||
"See documentation: ./docs/configuration.md",
|
||||
);
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(filePath: string): Promise<T> {
|
||||
const fs = await import("node:fs");
|
||||
const file = await fs.promises.readFile(filePath);
|
||||
return JSON.parse(file.toString("utf-8")) as T;
|
||||
}
|
||||
|
||||
/* eslint-disable no-process-env */
|
||||
export default {
|
||||
NODE_ENV: process.env.NODE_ENV || "develop",
|
||||
timezone: yamlConfig.timezone || "America/New_York",
|
||||
installDir: INSTALL_DIR,
|
||||
pkg: await readJsonFile<typeof PackageJson>(
|
||||
path.join(INSTALL_DIR, "package.json"),
|
||||
),
|
||||
platform: {
|
||||
baseUrl: yamlConfig.platform.baseUrl,
|
||||
},
|
||||
redis: {
|
||||
host: yamlConfig.redis?.host || "localhost",
|
||||
port: yamlConfig.redis?.port || 6379,
|
||||
password: yamlConfig.redis?.password,
|
||||
keyPrefix: yamlConfig.redis?.keyPrefix || "gadget:",
|
||||
},
|
||||
concurrency: yamlConfig.concurrency || 1,
|
||||
log: {
|
||||
console: {
|
||||
enabled: yamlConfig.logging?.console?.enabled === true,
|
||||
},
|
||||
file: {
|
||||
enabled: yamlConfig.logging?.file?.enabled === true,
|
||||
path: yamlConfig.logging?.file?.path
|
||||
? resolvePath(yamlConfig.logging.file.path)
|
||||
: path.join(INSTALL_DIR, "logs"),
|
||||
name: yamlConfig.logging?.file?.name || "gadget-tasks",
|
||||
maxWritesPerFile: yamlConfig.logging?.file?.maxWritesPerFile || 10000,
|
||||
maxFiles: yamlConfig.logging?.file?.maxFiles || 10,
|
||||
},
|
||||
levels: {
|
||||
debug: yamlConfig.logging?.levels?.debug === true,
|
||||
info: yamlConfig.logging?.levels?.info === true,
|
||||
warn: yamlConfig.logging?.levels?.warn === true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Configure GadgetLog for this package
|
||||
GadgetLog.consoleEnabled = yamlConfig.logging?.console?.enabled === true;
|
||||
if (yamlConfig.logging?.file?.enabled === true) {
|
||||
const logFileOptions = {
|
||||
basePath: yamlConfig.logging.file.path
|
||||
? resolvePath(yamlConfig.logging.file.path)
|
||||
: path.join(INSTALL_DIR, "logs"),
|
||||
name: yamlConfig.logging.file.name || "gadget-tasks",
|
||||
maxWritesPerFile: yamlConfig.logging.file.maxWritesPerFile || 10000,
|
||||
maxFiles: yamlConfig.logging.file.maxFiles || 10,
|
||||
};
|
||||
const defaultLogFile = new GadgetLogFile(logFileOptions);
|
||||
defaultLogFile.open();
|
||||
GadgetLog.defaultFile = defaultLogFile;
|
||||
}
|
||||
|
||||
/* eslint-enable no-process-env */
|
||||
@ -1,228 +0,0 @@
|
||||
// src/gadget-tasks.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import env from "./config/env.ts";
|
||||
|
||||
import PlatformService from "./services/platform.ts";
|
||||
import SchedulerService from "./services/scheduler.ts";
|
||||
import TaskLockService from "./services/lock.ts";
|
||||
|
||||
import { GadgetLog, detectWorkspace, type IDroneRegistration, type IProject } from "@gadget/api";
|
||||
import { GadgetProcess } from "./lib/process.ts";
|
||||
|
||||
const log = new GadgetLog({ name: "GadgetTasks", slug: "gadget-tasks" });
|
||||
|
||||
interface UserCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
class GadgetTasks extends GadgetProcess {
|
||||
private selectedDrone: IDroneRegistration | null = null;
|
||||
private startupDir: string | undefined = undefined;
|
||||
|
||||
get name(): string {
|
||||
return "GadgetTasks";
|
||||
}
|
||||
|
||||
get slug(): string {
|
||||
return "gadget-tasks";
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.hookProcessSignals();
|
||||
|
||||
/*
|
||||
* Detect workspace directory by walking up the directory tree.
|
||||
* This allows starting from anywhere within a workspace's hierarchy.
|
||||
*/
|
||||
|
||||
const detection = await detectWorkspace();
|
||||
this.startupDir = detection.startupDir;
|
||||
|
||||
if (!detection.found) {
|
||||
this.log.error("no workspace found", {
|
||||
startupDir: detection.startupDir,
|
||||
message: "Start gadget-tasks from within a managed workspace or its subdirectories. No .gadget/workspace.json found in any parent directory.",
|
||||
});
|
||||
throw new Error(
|
||||
"No workspace found. Cannot start gadget-tasks in an unmanaged directory.",
|
||||
);
|
||||
}
|
||||
|
||||
// Change to the workspace directory for all operations
|
||||
const workspaceDir = detection.workspaceDir!;
|
||||
process.chdir(workspaceDir);
|
||||
|
||||
this.log.info("workspace detected", {
|
||||
workspaceDir,
|
||||
startupDir: detection.startupDir,
|
||||
navigated: workspaceDir !== detection.startupDir,
|
||||
});
|
||||
|
||||
// 1. Acquire singleton lock via Redis
|
||||
const lockAcquired = await TaskLockService.acquire();
|
||||
if (!lockAcquired) {
|
||||
this.log.error("another instance of gadget-tasks is already running");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Get user credentials (CLI args or interactive prompt)
|
||||
const credentials = await this.getUserCredentials();
|
||||
|
||||
// 3. Authenticate with platform
|
||||
await PlatformService.start();
|
||||
await PlatformService.authenticate(credentials.email, credentials.password);
|
||||
this.log.info("authenticated with platform", { email: credentials.email });
|
||||
|
||||
// 4. Select a drone
|
||||
this.selectedDrone = await this.selectDrone();
|
||||
PlatformService.setSelectedDrone(this.selectedDrone);
|
||||
this.log.info("drone selected", {
|
||||
droneId: this.selectedDrone._id,
|
||||
hostname: this.selectedDrone.hostname,
|
||||
});
|
||||
|
||||
// 5. Connect Socket.IO
|
||||
await PlatformService.connectSocket();
|
||||
|
||||
// 6. Start scheduler
|
||||
await SchedulerService.start();
|
||||
|
||||
// 7. Fetch and schedule active tasks
|
||||
await this.scheduleAllTasks();
|
||||
|
||||
this.log.info(`gadget-tasks v${env.pkg.version} started, ${SchedulerService.scheduledCount} tasks scheduled`);
|
||||
}
|
||||
|
||||
async stop(): Promise<number> {
|
||||
this.log.info(`gadget-tasks v${env.pkg.version} shutting down`);
|
||||
|
||||
await SchedulerService.stop();
|
||||
await PlatformService.stop();
|
||||
await TaskLockService.release();
|
||||
|
||||
/*
|
||||
* Restore the original startup directory before exiting.
|
||||
*/
|
||||
if (this.startupDir) {
|
||||
process.chdir(this.startupDir);
|
||||
this.log.info("restored startup directory", { startupDir: this.startupDir });
|
||||
}
|
||||
|
||||
this.log.info("shutdown complete");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all active projects and schedule their enabled tasks.
|
||||
*/
|
||||
async scheduleAllTasks(): Promise<void> {
|
||||
const projects = await PlatformService.getProjects();
|
||||
const activeProjects = projects.filter((p) => p.status === "active");
|
||||
|
||||
let taskCount = 0;
|
||||
for (const project of activeProjects) {
|
||||
for (const task of project.tasks) {
|
||||
if (!task.enabled) continue;
|
||||
SchedulerService.scheduleTask(task, project as unknown as IProject);
|
||||
taskCount++;
|
||||
}
|
||||
}
|
||||
|
||||
this.log.info("tasks scheduled", {
|
||||
projectCount: activeProjects.length,
|
||||
taskCount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user credentials from CLI args or interactive prompt.
|
||||
* Same pattern as gadget-drone startup.
|
||||
*/
|
||||
async getUserCredentials(): Promise<UserCredentials> {
|
||||
const args = process.argv.slice(2);
|
||||
const userArg = args.find((a) => a.startsWith("--user="));
|
||||
const passArg = args.find((a) => a.startsWith("--password="));
|
||||
|
||||
if (userArg && passArg) {
|
||||
return {
|
||||
email: userArg.split("=")[1],
|
||||
password: passArg.split("=")[1],
|
||||
};
|
||||
}
|
||||
|
||||
const { input: inqInput, password: inqPassword } = await import("@inquirer/prompts");
|
||||
return {
|
||||
email: await inqInput({ message: "📧 Enter Email: " }),
|
||||
password: await inqPassword({ message: "🔑 Enter Password: " }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a drone for task execution. If only one is available,
|
||||
* auto-select it. Otherwise, present an interactive selection.
|
||||
*/
|
||||
async selectDrone(): Promise<IDroneRegistration> {
|
||||
const drones = await PlatformService.getAvailableDrones();
|
||||
if (drones.length === 0) {
|
||||
throw new Error("No available drones. Start a gadget-drone instance first.");
|
||||
}
|
||||
if (drones.length === 1) {
|
||||
this.log.info("auto-selecting only available drone", { droneId: drones[0]._id });
|
||||
return drones[0];
|
||||
}
|
||||
|
||||
console.log("\nAvailable Drones:");
|
||||
drones.forEach((d, i) => {
|
||||
console.log(` ${i + 1}. ${d.hostname} (${d.workspaceId}) - ${d.status}`);
|
||||
});
|
||||
|
||||
const { select: inqSelect } = await import("@inquirer/prompts");
|
||||
const selection = await inqSelect({
|
||||
message: "Select a drone for task execution:",
|
||||
choices: drones.map((d) => ({
|
||||
name: `${d.hostname} (${d.workspaceId})`,
|
||||
value: d,
|
||||
})),
|
||||
});
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
hookProcessSignals(): void {
|
||||
process.title = this.name;
|
||||
|
||||
process.on("unhandledRejection", async (error: Error) => {
|
||||
this.log.error("Unhandled rejection", { error, stack: error.stack });
|
||||
const exitCode = await this.stop();
|
||||
process.exit(exitCode);
|
||||
});
|
||||
|
||||
process.on("warning", (error) => {
|
||||
if (error.name === "DeprecationWarning") return;
|
||||
this.log.alert("warning", { error });
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
this.log.info("SIGINT received");
|
||||
const exitCode = await this.stop();
|
||||
process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const tasks = new GadgetTasks();
|
||||
await tasks.start();
|
||||
} catch (error) {
|
||||
console.error("failed to start gadget-tasks", error);
|
||||
process.exit(-1);
|
||||
}
|
||||
})();
|
||||
@ -1,19 +0,0 @@
|
||||
// src/lib/process.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import { GadgetComponent, GadgetLog } from "@gadget/api";
|
||||
|
||||
export abstract class GadgetProcess implements GadgetComponent {
|
||||
protected log: GadgetLog;
|
||||
|
||||
constructor() {
|
||||
this.log = new GadgetLog(this);
|
||||
}
|
||||
|
||||
abstract get name(): string;
|
||||
abstract get slug(): string;
|
||||
|
||||
abstract start(): Promise<void>;
|
||||
abstract stop(): Promise<number>;
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
// src/lib/service.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import { GadgetComponent, GadgetLog } from "@gadget/api";
|
||||
|
||||
export abstract class GadgetService implements GadgetComponent {
|
||||
public log: GadgetLog;
|
||||
|
||||
constructor() {
|
||||
this.log = new GadgetLog(this);
|
||||
}
|
||||
|
||||
abstract get name(): string;
|
||||
abstract get slug(): string;
|
||||
|
||||
abstract start(): Promise<void>;
|
||||
abstract stop(): Promise<void>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user