mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
refactor: Hub & Spoke (#203)
- Remove rpc and scheduler packages -Add `packages/hub` as a first-class workspace package - Move Scheduling Into The Hub - Rewire schedule-triggered runtime execution so the hub assigns work to spokes instead of delegating through RPC services - Replace RPC schedule CRUD and execution APIs with hub-native command handlers and shared schedule/event types. - Replace RPC-first runtime selection with `local` / `hub` / `remote` runtime modes in `@clinebot/core` - Clients can attach to a running session from the hub
This commit is contained in:
+9
-12
@@ -6,7 +6,7 @@ alwaysApply: true
|
||||
|
||||
# Cline SDK — Development Reference
|
||||
|
||||
Quick-reference for active development. For onboarding, workspace setup, publishing, and detailed workflow see [CONTRIBUTION.md](./CONTRIBUTION.md). For architecture and runtime flows see [ARCHITECTURE.md](./ARCHITECTURE.md). For API details see [DOC.md](./DOC.md).
|
||||
Quick-reference for active development. For onboarding, workspace setup, publishing, and detailed workflow see [CONTRIBUTING.md](./CONTRIBUTING.md). For architecture and runtime flows see [ARCHITECTURE.md](./ARCHITECTURE.md). For API details see [DOC.md](./DOC.md).
|
||||
|
||||
## Package Boundaries
|
||||
|
||||
@@ -15,23 +15,21 @@ Quick-reference for active development. For onboarding, workspace setup, publish
|
||||
- `@clinebot/shared`: shared contracts, schemas, path helpers, hook engine, extension registry, low-level utilities
|
||||
- `@clinebot/llms`: provider settings/config, model catalogs, provider manifests, gateway contracts, handler creation
|
||||
- `@clinebot/agents`: stateless agent loop, tool orchestration, hook/extension runtime, event streaming
|
||||
- `@clinebot/hub`: hub discovery, WebSocket clients, session helpers, and host-side daemon controls
|
||||
- `@clinebot/core`: stateful orchestration, session lifecycle, storage, config watching, plugin loading, default tools, telemetry
|
||||
|
||||
### Internal Package
|
||||
|
||||
- `@clinebot/enterprise`: enterprise identity adapters, control-plane sync, managed instruction materialization, claims-to-role mapping, telemetry bridging. Composes with core but `core` must not depend on it. Excluded from root SDK build/version/publish flows.
|
||||
- `@clinebot/scheduler`: scheduled execution, concurrency guards, routine persistence
|
||||
- `@clinebot/rpc`: gRPC/control-plane layer for sessions, events, approvals, schedules
|
||||
|
||||
### Dependency Direction
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
shared["@clinebot/shared"] --> llms["@clinebot/llms"] & agents["@clinebot/agents"] & rpc["@clinebot/rpc"] & core["@clinebot/core"]
|
||||
shared["@clinebot/shared"] --> llms["@clinebot/llms"] & agents["@clinebot/agents"] & core["@clinebot/core"] & hub["@clinebot/hub"]
|
||||
llms --> agents & core
|
||||
scheduler["@clinebot/scheduler"] --> rpc
|
||||
agents --> core
|
||||
rpc --> core
|
||||
core --> hub
|
||||
enterprise["@clinebot/enterprise"] --> agents & core & shared
|
||||
core --> apps["CLI / VS Code / Code App"]
|
||||
```
|
||||
@@ -40,7 +38,7 @@ Rules:
|
||||
- `shared` stays low-level and reusable
|
||||
- `agents` stays stateless — no session/storage/config concerns
|
||||
- `core` owns stateful orchestration
|
||||
- `rpc` owns transport/gateway concerns
|
||||
- `hub` owns host-side discovery/client concerns while `core` owns the hub runtime/server behavior
|
||||
- `enterprise` may depend on `core`, but not the reverse
|
||||
|
||||
## Change Routing
|
||||
@@ -49,9 +47,8 @@ Route changes to the package that owns the concern:
|
||||
|
||||
- model/provider schemas or handler behavior: `@clinebot/llms`
|
||||
- stateless loop, tool orchestration, streaming, hook/extension runtime: `@clinebot/agents`
|
||||
- session lifecycle, storage, config watching, default tools, plugin loading, telemetry: `@clinebot/core`
|
||||
- schedules and routine execution: `@clinebot/scheduler`
|
||||
- session gateway, approval routing, RPC contracts: `@clinebot/rpc`
|
||||
- hub discovery, attach flows, and session-oriented client helpers: `@clinebot/hub`
|
||||
- session lifecycle, storage, config watching, default tools, plugin loading, telemetry, and hub runtime services: `@clinebot/core`
|
||||
- enterprise identity, control-plane sync, materialization, claims mapping: `@clinebot/enterprise`
|
||||
- host-specific UX or shell behavior: app package
|
||||
|
||||
@@ -65,7 +62,7 @@ bun run test # run all tests
|
||||
bun run check # lint + build + typecheck + check-publish
|
||||
```
|
||||
|
||||
If you touch RPC/bootstrap/session flows, please update `ARCHITECTURE.md`.
|
||||
If you touch hub/bootstrap/session flows, please update `ARCHITECTURE.md`.
|
||||
|
||||
## Practical Guidance
|
||||
|
||||
@@ -84,7 +81,7 @@ If you touch RPC/bootstrap/session flows, please update `ARCHITECTURE.md`.
|
||||
## Documentation Responsibilities
|
||||
|
||||
- `README.md`: visitor-facing overview. Update when the repo story or package inventory changes.
|
||||
- `CONTRIBUTION.md`: onboarding, workflow, publishing. Update when contributor setup or release process changes.
|
||||
- `CONTRIBUTING.md`: onboarding, workflow, publishing. Update when contributor setup or release process changes.
|
||||
- `AGENTS.md` (this file): development reference. Update when package boundaries, dependency rules, or change routing changes.
|
||||
- `ARCHITECTURE.md`: design, boundaries, runtime flows. Update when system design or architectural constraints change.
|
||||
- `DOC.md`: API and behavior reference. Update when exported surfaces, lifecycle semantics, or runtime behavior changes.
|
||||
|
||||
+23
-33
@@ -19,23 +19,20 @@ The workspace is organized as a layered runtime stack.
|
||||
flowchart LR
|
||||
shared["@clinebot/shared"]
|
||||
llms["@clinebot/llms"]
|
||||
scheduler["@clinebot/scheduler"]
|
||||
agents["@clinebot/agents"]
|
||||
rpc["@clinebot/rpc"]
|
||||
core["@clinebot/core"]
|
||||
hub["@clinebot/hub"]
|
||||
enterprise["@clinebot/enterprise (internal)"]
|
||||
apps["Host Apps"]
|
||||
|
||||
llms --> shared
|
||||
scheduler --> shared
|
||||
agents --> llms
|
||||
agents --> shared
|
||||
rpc --> scheduler
|
||||
rpc --> shared
|
||||
core --> agents
|
||||
core --> llms
|
||||
core --> rpc
|
||||
core --> shared
|
||||
hub --> shared
|
||||
core --> hub
|
||||
enterprise --> agents
|
||||
enterprise --> core
|
||||
enterprise --> shared
|
||||
@@ -88,30 +85,18 @@ Design rule:
|
||||
|
||||
- `agents` should not own persistent storage or host lifecycle concerns.
|
||||
|
||||
### `@clinebot/scheduler`
|
||||
### `@clinebot/hub`
|
||||
|
||||
Owns scheduled execution:
|
||||
Owns host-side hub access:
|
||||
|
||||
- cron definitions
|
||||
- execution concurrency/limits
|
||||
- run history and schedule persistence
|
||||
- local discovery and health probing
|
||||
- WebSocket client helpers
|
||||
- session-oriented hub client adapters
|
||||
- detached host bootstrap helpers
|
||||
|
||||
Design rule:
|
||||
|
||||
- scheduling stays separate from the interactive/runtime composition layer.
|
||||
|
||||
### `@clinebot/rpc`
|
||||
|
||||
Owns cross-process transport:
|
||||
|
||||
- session/task/event routing
|
||||
- approvals
|
||||
- schedule execution gateway
|
||||
- runtime RPC contracts
|
||||
|
||||
Design rule:
|
||||
|
||||
- RPC should expose and transport runtime capabilities, not re-own business logic that already belongs in `core`.
|
||||
- `@clinebot/hub` stays thin and client-oriented; the stateful hub implementation lives in `@clinebot/core`.
|
||||
|
||||
### `@clinebot/core`
|
||||
|
||||
@@ -120,6 +105,7 @@ Owns stateful orchestration:
|
||||
- runtime composition
|
||||
- session lifecycle
|
||||
- storage and persistence
|
||||
- hub server/runtime services
|
||||
- config watching/loading and watcher projections
|
||||
- default host tool assembly
|
||||
- plugin discovery/loading
|
||||
@@ -159,14 +145,15 @@ Design rules:
|
||||
6. `@clinebot/agents` runs the loop using `@clinebot/llms` handlers.
|
||||
7. `@clinebot/core` persists state, artifacts, and metadata.
|
||||
|
||||
### RPC-Backed Runtime
|
||||
### Hub-Backed Runtime
|
||||
|
||||
1. Host constructs a `RuntimeHost` through `@clinebot/core`.
|
||||
2. `@clinebot/core` selects `RpcRuntimeHost` through `packages/core/src/runtime/host.ts`.
|
||||
3. `RpcRuntimeHost` translates start/turn/lifecycle calls into RPC requests and adapts remote events back into the shared `RuntimeHost` event contract.
|
||||
4. The remote runtime executes the agent loop using `@clinebot/agents` and `@clinebot/llms`.
|
||||
5. `@clinebot/rpc` brokers sessions, events, approvals, and schedules.
|
||||
6. `@clinebot/scheduler` runs behind RPC for schedule-triggered execution.
|
||||
2. `@clinebot/core` selects `HubRuntimeHost` or `RemoteRuntimeHost` through `packages/core/src/runtime/host.ts`.
|
||||
3. When no compatible local hub is already discovered, `@clinebot/core` can spawn a detached hub daemon and reconnect through discovery.
|
||||
4. Hosts attach and detach from shared sessions without stopping the authority runtime, so another client can keep streaming or resume the same session later.
|
||||
5. The hub-hosted runtime executes the agent loop using `@clinebot/agents` and `@clinebot/llms`.
|
||||
6. `@clinebot/core` hub services broker sessions, events, approvals, schedules, and client-owned runtime capabilities such as session-local tool executors.
|
||||
7. `@clinebot/hub` clients adapt command/reply and event streams back into host-facing APIs.
|
||||
|
||||
### Enterprise-Managed Runtime
|
||||
|
||||
@@ -223,14 +210,17 @@ Core exposes one shared execution boundary: `RuntimeHost`.
|
||||
Concrete implementations:
|
||||
|
||||
- `LocalRuntimeHost` for in-process execution
|
||||
- `RpcRuntimeHost` for RPC-backed execution
|
||||
- `HubRuntimeHost` for shared local hub execution
|
||||
- `RemoteRuntimeHost` for explicit remote hub endpoints
|
||||
|
||||
Design implication:
|
||||
|
||||
- host selection happens in `packages/core/src/runtime/host.ts`
|
||||
- `ClineCore` delegates uniformly to `RuntimeHost` and does not branch on local vs RPC behavior
|
||||
- `ClineCore` delegates uniformly to `RuntimeHost` and does not branch on local vs hub behavior
|
||||
- transport-specific translation belongs inside concrete hosts, not in top-level orchestration
|
||||
- `RuntimeHost` inputs stay transport-safe, while `ClineCore.start(...)` is the app-facing facade that normalizes broad local config before delegation
|
||||
- `RuntimeSessionConfig` is transport-neutral across local, shared hub, and remote hub modes; host-local bootstrap concerns stay under `localRuntime`
|
||||
- client-local runtime behaviors that must survive hub mode, such as `defaultToolExecutors`, are attached at session start and proxied through hub capability requests instead of changing host selection
|
||||
|
||||
## Logging
|
||||
|
||||
|
||||
@@ -13,9 +13,8 @@ This repo is a WIP framework for building and orchestrating AI agents. Full refa
|
||||
| `@clinebot/shared` | Contracts, schemas, path helpers, hook engine, extension registry |
|
||||
| `@clinebot/llms` | Provider settings, model catalogs, manifests, handler creation |
|
||||
| `@clinebot/agents` | Stateless agent loop, tool orchestration, hook/extension runtime |
|
||||
| `@clinebot/scheduler` | Scheduled execution, concurrency guards, routine persistence |
|
||||
| `@clinebot/rpc` | gRPC/control-plane for sessions, events, approvals, schedules |
|
||||
| `@clinebot/core` | Stateful orchestration, session lifecycle, storage, config, telemetry |
|
||||
| `@clinebot/hub` | Hub discovery, client helpers, and host-side daemon controls |
|
||||
| `@clinebot/core` | Stateful orchestration, session lifecycle, storage, config, telemetry, and hub runtime services |
|
||||
|
||||
### Internal Package
|
||||
|
||||
@@ -23,7 +22,7 @@ This repo is a WIP framework for building and orchestrating AI agents. Full refa
|
||||
|
||||
### Apps
|
||||
|
||||
- `apps/cli`: CLI host and RPC server management
|
||||
- `apps/cli`: CLI host and local hub management
|
||||
- `apps/code`: Tauri + Next.js desktop app
|
||||
- `apps/vscode`: VS Code extension
|
||||
- `apps/examples`: sample consumers and integration examples
|
||||
@@ -56,13 +55,13 @@ Changes to published SDK packages require `bun run build:sdk`. Direct CLI runs p
|
||||
|
||||
Internal-only packages (`packages/enterprise`) are excluded from root build/version/publish flows — work on them with package-scoped commands.
|
||||
|
||||
RPC-backed hosts use shared runtime ensure logic and replace incompatible owned sidecars automatically when the RPC runtime build changes. The build identity is keyed from `@clinebot/core` and `@clinebot/rpc` package versions. If you touch RPC bootstrap, preserve the startup lock and owner-scoped discovery behavior so multiple builds can coexist safely.
|
||||
Hub-backed hosts use shared workspace discovery and owned daemon startup logic. If you touch hub bootstrap, preserve the startup lock and owner-scoped discovery behavior so multiple builds can coexist safely.
|
||||
|
||||
### Debug Builds
|
||||
|
||||
- Set `CLINE_BUILD_ENV=development` for debug builds. Spawned Node/Bun subprocesses get an inspector endpoint plus `--enable-source-maps`.
|
||||
- By default, child-process inspector ports are ephemeral (`--inspect=127.0.0.1:0`) to avoid collisions across parallel dev runs.
|
||||
- Set `CLINE_DEBUG_HOST` and `CLINE_DEBUG_PORT_BASE` to opt into deterministic role-based ports. With `CLINE_DEBUG_PORT_BASE=9230`, the roles map to RPC `9230`, hook worker `9231`, plugin sandbox `9232`, connector child `9233`, fallback sandbox `9234`.
|
||||
- Set `CLINE_DEBUG_HOST` and `CLINE_DEBUG_PORT_BASE` to opt into deterministic role-based ports. With `CLINE_DEBUG_PORT_BASE=9230`, the roles map to hub `9230`, hook worker `9231`, plugin sandbox `9232`, connector child `9233`, fallback sandbox `9234`.
|
||||
- Fallback chain: `CLINE_BUILD_ENV` → `NODE_ENV` → Bun `--conditions=development`.
|
||||
- To debug the CLI process itself: `cd apps/cli && CLINE_BUILD_ENV=development bun --conditions=development --inspect-brk=6499 ./src/index.ts "hey"`.
|
||||
- The workspace includes a VS Code launch config (`Launch CLI Debugger`) that uses `"type": "bun"` (requires `oven.bun-vscode`).
|
||||
@@ -77,7 +76,7 @@ bun run types # typecheck all packages
|
||||
bun run check # lint + build + typecheck + check-publish
|
||||
```
|
||||
|
||||
If you touch RPC/bootstrap/session flows, prefer both unit coverage and an end-to-end sanity check.
|
||||
If you touch hub/bootstrap/session flows, prefer both unit coverage and an end-to-end sanity check.
|
||||
|
||||
## Publishing
|
||||
|
||||
+61
-44
@@ -75,7 +75,7 @@ Behavior notes:
|
||||
|
||||
### Extensions vs Hooks
|
||||
|
||||
- extensions register contributions such as tools, commands, shortcuts, flags, renderers, and providers
|
||||
- extensions register contributions such as tools, commands, message builders, renderers, and providers
|
||||
- hooks intercept lifecycle stages and can influence execution
|
||||
|
||||
Use extensions for additive runtime surface.
|
||||
@@ -91,38 +91,21 @@ Behavior:
|
||||
- hosts may also supply `prepareTurn` to rewrite message history or the system prompt before the turn is sent
|
||||
- this is the primary seam for host-owned context pipelines such as compaction
|
||||
|
||||
## `@clinebot/scheduler`
|
||||
## `@clinebot/hub`
|
||||
|
||||
Primary role: scheduled execution and bounded autonomous routines. Internally consumed by RPC.
|
||||
Primary role: host-side hub discovery and client access.
|
||||
|
||||
Important exported areas:
|
||||
|
||||
- schedule store
|
||||
- scheduler service
|
||||
- cron helpers
|
||||
- concurrency/resource limiter
|
||||
- local hub discovery helpers
|
||||
- WebSocket hub client helpers
|
||||
- `HubSessionClient`
|
||||
- host-side ensure/start helpers
|
||||
|
||||
Behavior notes:
|
||||
|
||||
- scheduler enforces timeout and concurrency limits
|
||||
- scheduler is typically consumed behind RPC rather than directly by most host apps
|
||||
|
||||
## `@clinebot/rpc`
|
||||
|
||||
Primary role: cross-process runtime gateway.
|
||||
|
||||
Important exported areas:
|
||||
|
||||
- RPC server startup
|
||||
- client helpers
|
||||
- runtime session APIs
|
||||
- approval/event routing
|
||||
- schedule gateway APIs
|
||||
|
||||
Behavior notes:
|
||||
|
||||
- RPC transports runtime/session capabilities across process boundaries
|
||||
- business logic should stay in lower packages where possible rather than being duplicated in the RPC layer
|
||||
- `@clinebot/hub` is intentionally thin and client-oriented
|
||||
- stateful hub runtime behavior lives in `@clinebot/core`
|
||||
|
||||
## `@clinebot/core`
|
||||
|
||||
@@ -131,7 +114,7 @@ Primary role: stateful orchestration over the stateless agent runtime.
|
||||
Important exported areas:
|
||||
|
||||
- `ClineCore`
|
||||
- `RuntimeHost`, `LocalRuntimeHost`, `RpcRuntimeHost`, and `createRuntimeHost`
|
||||
- `RuntimeHost`, `LocalRuntimeHost`, `HubRuntimeHost`, `RemoteRuntimeHost`, and `createRuntimeHost`
|
||||
- runtime builder
|
||||
- config watchers/loaders
|
||||
- config-side watcher projection helpers
|
||||
@@ -245,6 +228,7 @@ Behavior:
|
||||
- queued turns are stored as pending prompts
|
||||
- steer inserts at the front of the pending queue
|
||||
- attachments are preserved
|
||||
- interactive sessions automatically treat a new send as `delivery: "queue"` while a run is already in progress unless the caller explicitly requests another delivery mode
|
||||
- core emits queue-related events and should be treated as the source of truth
|
||||
|
||||
### Telemetry
|
||||
@@ -756,25 +740,58 @@ The agent team runtime gives Cline the ability to spawn and coordinate multiple
|
||||
|
||||
#### Available Team Tools
|
||||
|
||||
**Teammate Management:**
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `team_spawn_teammate` | Spawn a new teammate agent with a given role prompt |
|
||||
| `team_shutdown_teammate` | Shut down a running teammate by ID |
|
||||
| `team_run_task` | Delegate a task to a teammate — sync (wait for result) or async (background) |
|
||||
| `team_await_run` | Wait for a specific async run to complete |
|
||||
| `team_await_all_runs` | Wait for all active async runs to complete |
|
||||
| `team_cancel_run` | Cancel an in-progress async run |
|
||||
| `team_task` | Shared task board — create, list, claim, complete, or block tasks |
|
||||
| `team_send_message` | Send a direct mailbox message to one teammate |
|
||||
| `team_broadcast` | Broadcast a message to all teammates |
|
||||
| `team_read_mailbox` | Read incoming mailbox messages |
|
||||
| `team_log_update` | Append a progress entry to the shared mission log |
|
||||
| `team_status` | Snapshot of all teammates, task counts, mailbox, and mission log stats |
|
||||
| `team_create_outcome` | Create a converged team outcome document |
|
||||
| `team_attach_outcome_fragment` | Attach a content fragment to an outcome section |
|
||||
| `team_review_outcome_fragment` | Approve or reject an outcome fragment |
|
||||
| `team_finalize_outcome` | Finalize a completed outcome |
|
||||
| `team_list_outcomes` | List all team outcomes |
|
||||
| `team_spawn_teammate` | Spawn a new teammate agent with a given agentId and rolePrompt. Only the lead agent can spawn. |
|
||||
| `team_shutdown_teammate` | Shut down a running teammate by agentId. Only the lead agent can manage teammates. |
|
||||
| `team_status` | Return a snapshot of team members, task counts, mailbox, and mission log stats. |
|
||||
|
||||
**Task Delegation:**
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `team_run_task` | Route a delegated task to a teammate. Choose sync (wait for result) or async (run in background). Sync mode only allows one call per agent per turn. |
|
||||
| `team_list_runs` | List teammate runs started with team_run_task in async mode, including live activity/progress fields. |
|
||||
| `team_await_runs` | Wait for async teammate runs. Provide runId to wait for one specific run, or omit it to wait for all active async runs. Uses a long timeout (1 hour). |
|
||||
| `team_cancel_run` | Cancel one async teammate run by runId. |
|
||||
|
||||
**Task Board:**
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `team_task` | Manage shared team tasks with action-specific payloads. Actions: create (requires title and description, optional dependsOn and assignee), list (optional status and assignee filters), claim (mark task in_progress), complete (finish task with summary), block (mark as blocked with reason). |
|
||||
|
||||
**Communication:**
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `team_send_message` | Send a mailbox message to a specific teammate with optional subject, body, and taskId. |
|
||||
| `team_broadcast` | Broadcast a message to all teammates with optional subject, body, and taskId. |
|
||||
| `team_read_mailbox` | Read the current agent's mailbox, with optional unreadOnly filter and automatic mark-as-read. |
|
||||
|
||||
**Mission Log:**
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `team_mission_log` | Append a mission log update with kind (progress, handoff, blocked, decision, done, error), summary, optional evidence array, and nextAction. |
|
||||
|
||||
**Outcomes:**
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `team_create_outcome` | Create a converged team outcome document with a title and optional requiredSections array (defaults to current_state, boundary_analysis, interface_proposal). |
|
||||
| `team_attach_outcome_fragment` | Attach a content fragment to an outcome section with optional sourceRunId. |
|
||||
| `team_review_outcome_fragment` | Review (approve/reject) one outcome fragment. |
|
||||
| `team_finalize_outcome` | Finalize a completed outcome. |
|
||||
| `team_list_outcomes` | List all team outcomes. |
|
||||
|
||||
**Cleanup:**
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `team_cleanup` | Clean up the team runtime. Only the lead agent can run cleanup. Fails if teammates are still running. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-6
@@ -9,8 +9,7 @@ It is a Bun workspace centered around a small stack of reusable packages:
|
||||
- `@clinebot/shared`: shared contracts, schemas, path helpers, and runtime utilities
|
||||
- `@clinebot/llms`: model catalogs, shared provider contracts, and AI SDK-backed handler creation
|
||||
- `@clinebot/agents`: stateless agent loop, tools, hooks, and extension primitives
|
||||
- `@clinebot/scheduler`: scheduled execution and concurrency control
|
||||
- `@clinebot/rpc`: cross-process runtime gateway
|
||||
- `@clinebot/hub`: hub discovery, hub clients, and host-side daemon helpers
|
||||
- `@clinebot/core`: stateful orchestration, sessions, storage, and runtime assembly
|
||||
- `@clinebot/enterprise`: used for internal enterprise integrations. It is intentionally excluded from the root SDK build/version/publish flows.
|
||||
|
||||
@@ -22,7 +21,7 @@ This repo is the implementation workspace for the next-generation Cline SDK.
|
||||
|
||||
Choose documentation by your question:
|
||||
|
||||
- **Getting started**: [CONTRIBUTION.md](./CONTRIBUTION.md) covers workspace setup, development workflow, debugging, and publishing
|
||||
- **Getting started**: [CONTRIBUTING.md](./CONTRIBUTING.md) covers workspace setup, development workflow, debugging, and publishing
|
||||
- **Active development**: [AGENTS.md](./AGENTS.md) for package boundaries, dependency rules, change routing, and verification
|
||||
- **System design**: [ARCHITECTURE.md](./ARCHITECTURE.md) for design decisions and runtime flows
|
||||
- **API reference**: [DOC.md](./DOC.md) for detailed package/behavior specifications
|
||||
@@ -39,11 +38,10 @@ Choose documentation by your question:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
shared["@clinebot/shared"] --> llms["@clinebot/llms"] & agents["@clinebot/agents"] & rpc["@clinebot/rpc"] & core["@clinebot/core"]
|
||||
shared["@clinebot/shared"] --> llms["@clinebot/llms"] & agents["@clinebot/agents"] & core["@clinebot/core"] & hub["@clinebot/hub"]
|
||||
llms --> agents & core
|
||||
scheduler["@clinebot/scheduler"] --> rpc
|
||||
agents --> core
|
||||
rpc --> core
|
||||
core --> hub
|
||||
enterprise["@clinebot/enterprise (internal)"] --> agents & core & shared
|
||||
core --> apps["CLI / VS Code / Code App"]
|
||||
```
|
||||
|
||||
+19
-82
@@ -91,8 +91,6 @@ clite -i
|
||||
clite -i -s "You are a pirate" "Tell me about the sea"
|
||||
clite -i "Let's work on this together. First, analyze the current state and suggest next steps."
|
||||
|
||||
# Disable defaults tools, spawn(subagent), teams explicitly
|
||||
clite --no-tools --no-spawn --no-teams "Answer from general knowledge only"
|
||||
# Require approval before each tool call
|
||||
clite --autoapprove false "Inspect and modify this repository"
|
||||
# Explicitly enable auto-approval for all tools
|
||||
@@ -105,15 +103,14 @@ cat file.txt | clite "Summarize this"
|
||||
clite --team-name my-team "Plan, implement, and verify release checklist"
|
||||
clite --team-name my-team "Continue yesterday's team workflow"
|
||||
|
||||
# Show usage stats (tokens + estimated cost when available)
|
||||
clite -u --timings "Explain quantum computing"
|
||||
# Show usage stats (includes elapsed time, tokens, and estimated cost when available)
|
||||
clite -u "Explain quantum computing"
|
||||
|
||||
# Override consecutive internal mistake limit for this run (default: 3)
|
||||
clite --max-consecutive-mistakes 5 "Fix failing tests"
|
||||
# Common with auto-approve/yolo-style runs
|
||||
clite --auto-approve-all --max-consecutive-mistakes 5 "Refactor this package"
|
||||
clite --autoapprove true --max-consecutive-mistakes 5 "Refactor this package"
|
||||
# Explicit yolo also enables submit_and_exit and disables spawn/team tools by default
|
||||
# Re-enable them explicitly with --spawn and/or --teams when needed
|
||||
clite --yolo --max-consecutive-mistakes 5 "Refactor this package"
|
||||
|
||||
# Stream structured NDJSON output
|
||||
@@ -204,23 +201,7 @@ clite config
|
||||
# For one-shot auto-exit behavior, pass a prompt argument.
|
||||
# Exit interactive mode with Ctrl+D (or Ctrl+C when idle).
|
||||
|
||||
# INTERNAL: RPC gateway commands for host integration and runtime management
|
||||
# Start the RPC gateway server
|
||||
clite rpc start
|
||||
clite rpc start --address 127.0.0.1:4317
|
||||
# Check whether an RPC gateway is running
|
||||
clite rpc status
|
||||
clite rpc status --address 127.0.0.1:4317
|
||||
# Request RPC gateway shutdown
|
||||
clite rpc stop
|
||||
clite rpc stop --address 127.0.0.1:4317
|
||||
# Ensure a compatible runtime server is available (JSON output for host apps)
|
||||
clite rpc ensure --address 127.0.0.1:4317 --json
|
||||
# For new client to call to register with the RPC gateway
|
||||
clite rpc register --address 127.0.0.1:4317 --client-type desktop --client-id code-desktop
|
||||
clite rpc register --meta app=code --meta host=tauri
|
||||
|
||||
# Schedule agents on cron-like intervals (runs through RPC server runtime)
|
||||
# Schedule agents on cron-like intervals
|
||||
clite schedule create "Daily code review" \
|
||||
--cron "0 9 * * MON-FRI" \
|
||||
--prompt "Review PRs opened yesterday and summarize issues." \
|
||||
@@ -272,10 +253,9 @@ During OAuth login, `clite` tries to open the authorization URL in your default
|
||||
- Sign in with OCA
|
||||
- Use your own API key (provider + model + optional base URL)
|
||||
|
||||
RPC runtime note:
|
||||
Runtime note:
|
||||
|
||||
- RPC chat payload parsers normalize invalid optional `maxIterations` values (including JSON `null`) to `undefined` so sessions do not terminate immediately with `finishReason="max_iterations"` at iteration 0.
|
||||
- RPC-backed sessions share one persistent hook service per local RPC runtime server. Direct local CLI runs still use one persistent `clite hook-worker` per CLI runtime.
|
||||
- Hook dispatch now runs in-process against the active runtime session instead of spinning up a separate `clite hook-worker` service.
|
||||
|
||||
## Options
|
||||
|
||||
@@ -297,23 +277,14 @@ RPC runtime note:
|
||||
| `--acp` | ACP (Agent Client Protocol) mode |
|
||||
| `--thinking` | Enable model reasoning when supported |
|
||||
| `--reasoning-effort <none\|low\|medium\|high\|xhigh>` | Set explicit model reasoning effort (default: `none`, or `medium` when `--thinking` is set) |
|
||||
| `-u, --usage` | Show token usage and estimated cost |
|
||||
| `--timings` | Show timing details |
|
||||
| `-u, --usage` | Show elapsed time, token usage, and estimated cost |
|
||||
| `--json` | Output NDJSON instead of styled text |
|
||||
| `--refresh-models` | Refresh the provider model catalog for this run |
|
||||
| `--sandbox` | Use isolated local state instead of `~/.cline` |
|
||||
| `--sandbox-dir <path>` | Sandbox state dir (default: `$CLINE_SANDBOX_DATA_DIR` or `/tmp/cline-sandbox`) |
|
||||
| `--no-tools` | Disable default tools |
|
||||
| `--no-spawn` | Disable `spawn_agent` |
|
||||
| `--no-teams` | Disable team tools/runtime |
|
||||
| `--auto-approve-all` | Skip tool approval prompts |
|
||||
| `--autoapprove [true\|false]` | Set tool auto-approval for all tools |
|
||||
| `-y, --yolo` | Skip tool approval prompts, enable `submit_and_exit`, and disable spawn/team tools by default unless `--spawn` / `--teams` are also passed |
|
||||
| `--tool-enable <name>` | Explicitly enable one tool |
|
||||
| `--tool-disable <name>` | Explicitly disable one tool |
|
||||
| `-y, --yolo` | Skip tool approval prompts, enable `submit_and_exit`, and disable spawn/team tools by default |
|
||||
| `--team-name <name>` | Override the runtime team state name |
|
||||
| `--mission-step-interval <n>` | Mission log update cadence in meaningful steps |
|
||||
| `--mission-time-interval-ms <ms>` | Mission log update cadence in milliseconds |
|
||||
| `-h, --help` | Show help (exits immediately) |
|
||||
| `-v, --verbose` | Show verbose runtime diagnostics |
|
||||
| `-V, --version` | Show version (exits immediately) |
|
||||
@@ -323,20 +294,19 @@ RPC runtime note:
|
||||
Top-level commands:
|
||||
|
||||
- `clite config` - Open the interactive config view
|
||||
- `clite task|t [options] <prompt>` - Legacy command alias for running a task
|
||||
- `clite history|h [options]` - Legacy command alias for listing history
|
||||
- `clite task|t [options] <prompt>` - Run a task with the given prompt
|
||||
- `clite history|h [options]` - List session history or manage saved sessions
|
||||
- `clite checkpoint [options]` - Inspect or restore session checkpoints
|
||||
- `clite version` - Show CLI version
|
||||
- `clite update [options]` - Reserved command; currently prints a not-implemented message
|
||||
- `clite auth <provider>` - Authenticate or seed provider credentials
|
||||
- `clite connect <adapter>` - Run a chat connector bridge (`telegram`, `gchat`, `whatsapp`)
|
||||
- `clite connect --stop [adapter]` - Stop connector bridge processes and their sessions
|
||||
- `clite list <workflows|rules|skills|agents|history|hooks|mcp>` - List configs, history, or hook paths
|
||||
- `clite schedule <command>` - Create and manage scheduled runs
|
||||
- `clite sessions <list|update|delete>` - Inspect or edit saved sessions
|
||||
- `clite dev log` - Open the CLI runtime log file
|
||||
- `clite doctor` - Inspect local CLI/RPC health and stale processes
|
||||
- `clite doctor` - Inspect local CLI health and stale processes
|
||||
- `clite hook` - Handle a hook payload from stdin
|
||||
- `clite rpc <command>` - Manage the local RPC runtime server
|
||||
- `clite hub` - Manage the local hub daemon
|
||||
|
||||
Connector shortcuts:
|
||||
|
||||
@@ -346,11 +316,8 @@ Connector shortcuts:
|
||||
- `clite connect <adapter> --help` - Show adapter-specific options and examples
|
||||
- `--hook-command <command>` - Run a shell command for connector events
|
||||
|
||||
RPC and schedule shortcuts:
|
||||
Schedule shortcuts:
|
||||
|
||||
- `clite rpc <start|status|stop|ensure> [--address <host:port>]` - Manage the RPC server
|
||||
- `clite rpc register --client-type <type> --client-id <id>` - Register a client with the RPC server
|
||||
- `clite rpc ensure --json` - Ensure the current build's compatible RPC sidecar and print JSON
|
||||
- `clite schedule create <name> --cron "<expr>" --prompt "<text>" --workspace <path>` - Create a scheduled run
|
||||
- `clite schedule <create|list|get|update|pause|resume|delete|trigger|history|stats|active|upcoming|import|export>` - Manage schedules and execution history
|
||||
|
||||
@@ -368,13 +335,6 @@ Auth quick-setup flags:
|
||||
- `-m, --modelid <id>`
|
||||
- `-b, --baseurl <url>` (OpenAI/OpenAI-compatible quick setup)
|
||||
|
||||
MCP list examples:
|
||||
|
||||
```bash
|
||||
clite list mcp
|
||||
clite list mcp --json
|
||||
```
|
||||
|
||||
## Tool Approval
|
||||
|
||||
Tool calls are auto-approved by default. Use `--autoapprove false` to require review before tool execution.
|
||||
@@ -396,7 +356,6 @@ Approve tool "<tool_name>" with input <preview>? [y/N]
|
||||
- Enter `y` or `yes` to approve.
|
||||
- Enter anything else (or press Enter) to reject.
|
||||
- If stdin/stdout is not a TTY, required-approval calls are denied in terminal mode.
|
||||
- RPC-backed prompt runs also honor required approvals: approval requests are relayed through RPC, prompted in the CLI TTY, and responded back to the runtime before tool execution continues.
|
||||
|
||||
Desktop-integrated approval mode is also supported via env wiring:
|
||||
|
||||
@@ -405,25 +364,6 @@ Desktop-integrated approval mode is also supported via env wiring:
|
||||
|
||||
In desktop mode, CLI writes a request JSON file and waits for a matching decision JSON file.
|
||||
|
||||
## RPC Server
|
||||
|
||||
`clite rpc start` starts the `@clinebot/rpc` gRPC gateway.
|
||||
|
||||
- Default address: `127.0.0.1:4317`
|
||||
- Override with `--address <host:port>` or `CLINE_RPC_ADDRESS`
|
||||
- Startup behavior: checks health first; if already running at that address, it prints the running server id and exits without starting a duplicate
|
||||
- Status check: `clite rpc status` prints running/not-running and returns exit code `0` when healthy (`1` when not running)
|
||||
- Shutdown: `clite rpc stop` requests graceful shutdown for the target address; `clite rpc start` can also be stopped with Ctrl+C / `SIGTERM`
|
||||
- Ensure: `clite rpc ensure` reuses the current build's compatible sidecar when possible; if an older or foreign listener is present it can launch a fresh sidecar on a new available port and report that effective address
|
||||
- Compatibility check: `rpc ensure` requires runtime chat methods including `StartRuntimeSession`, `SendRuntimeSession`, `AbortRuntimeSession`, and `StopRuntimeSession`.
|
||||
- Client registration: `clite rpc register --client-type <type> [--client-id <id>] [--meta key=value]...` registers host identity for RPC clients
|
||||
- Runtime APIs: `clite rpc start` wires server-side runtime handlers for `StartRuntimeSession`, `SendRuntimeSession`, and `AbortRuntimeSession` (used by `@clinebot/code` and CLI runtime actions)
|
||||
- Runtime event bridge: runtime handlers publish live `runtime.chat.*` events via RPC `PublishEvent`, so subscribed clients can consume real-time text/tool updates through `StreamEvents`
|
||||
- Team event bridge: runtime handlers also publish typed team progress/lifecycle events (`runtime.team.progress.v1`, `runtime.team.lifecycle.v1`) with status-board projections
|
||||
- Tool approval bridge: runtime handlers publish `approval.requested` and wait for RPC responses; CLI prompt runs consume these requests and return approval decisions through RPC.
|
||||
- CLI streaming: RPC-backed prompt runs subscribe to `runtime.chat.*` during each turn, so text/tool output is rendered incrementally in the terminal.
|
||||
- Prompt startup behavior: regular `clite "<prompt>"` runs try to connect directly to `CLINE_RPC_ADDRESS` first. If no server is running, one is spawned in the background and the CLI waits briefly for it to bind. If the background spawn fails, the CLI falls back to an in-process local runtime.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `ANTHROPIC_API_KEY` - API key for Anthropic
|
||||
@@ -432,7 +372,6 @@ In desktop mode, CLI writes a request JSON file and waits for a matching decisio
|
||||
- `CLINE_SANDBOX` - Set to `1` to force sandbox mode
|
||||
- `CLINE_SANDBOX_DATA_DIR` - Override sandbox state directory
|
||||
- `CLINE_TEAM_DATA_DIR` - Override team persistence directory
|
||||
- `CLINE_RPC_ADDRESS` - Address used by `clite rpc start` (default `127.0.0.1:4317`)
|
||||
- `CLINE_BUILD_ENV` - Runtime build mode for SDK-owned subprocess launches (`development` adds `node|bun --inspect=127.0.0.1:0 --enable-source-maps` by default; falls back to `NODE_ENV` or `--conditions=development`)
|
||||
- `CLINE_DEBUG_HOST` - Override the host used for development inspector listeners (default `127.0.0.1`)
|
||||
- `CLINE_DEBUG_PORT_BASE` - Override the base inspector port for development child processes; when unset, child processes use ephemeral inspector ports
|
||||
@@ -454,7 +393,7 @@ For OAuth providers (`cline`, `openai-codex`, `oca`), authenticate explicitly wi
|
||||
|
||||
- `CLINE_BUILD_ENV=development` enables debugger ports for SDK-owned spawned Node/Bun subprocesses.
|
||||
- By default, child processes use ephemeral inspector ports to avoid collisions.
|
||||
- Set `CLINE_DEBUG_PORT_BASE=9230` if you want deterministic role-based ports such as RPC `9230`, hook worker `9231`, plugin sandbox `9232`, connector child `9233`.
|
||||
- Set `CLINE_DEBUG_PORT_BASE=9230` if you want deterministic role-based ports such as hook worker `9231`, plugin sandbox `9232`, connector child `9233`.
|
||||
- Those ports do not apply to the top-level CLI when it is running under Bun. To debug the Bun CLI process itself, launch the real CLI entrypoint under Bun with an inspector port such as:
|
||||
|
||||
```bash
|
||||
@@ -471,12 +410,10 @@ CLINE_BUILD_ENV=development bun --conditions=development --inspect-brk=6499 ./sr
|
||||
`clite` uses a `pino`-backed adapter that targets the core `BasicLogger` contract:
|
||||
|
||||
- CLI runtime passes `logger` directly into local `@clinebot/core` sessions.
|
||||
- RPC-backed sessions include a serialized logger payload in `RpcChatStartSessionRequest.logger`; the RPC runtime reconstructs the same `pino` settings and injects them into core.
|
||||
- Hosts can attach stable runtime logger bindings (for example `clientId`, `clientType`, `clientApp`) through `RpcChatRuntimeLoggerConfig.bindings`.
|
||||
- `clite rpc register` and `clite rpc start` emit activation/registration log records so startup ownership is visible in logs.
|
||||
- Logger behavior is consistent between local and RPC runtime execution paths while preserving a transport-safe config boundary.
|
||||
- Hub-backed sessions include a serialized logger payload in `ChatStartSessionRequest.logger`; the runtime reconstructs the same `pino` settings and injects them into core.
|
||||
- Hosts can attach stable runtime logger bindings (for example `clientId`, `clientType`, `clientApp`) through `RuntimeLoggerConfig.bindings`.
|
||||
|
||||
After login, OAuth credentials are persisted with `auth.expiresAt`, and `@clinebot/core` refreshes these tokens automatically during session turns (including long-lived RPC runtime sessions).
|
||||
After login, OAuth credentials are persisted with `auth.expiresAt`, and `@clinebot/core` refreshes these tokens automatically during session turns.
|
||||
|
||||
On startup, `clite` also attempts a legacy settings import:
|
||||
|
||||
@@ -490,7 +427,7 @@ Custom provider registry notes:
|
||||
|
||||
- Provider runtime settings continue to persist in `<CLINE_DATA_DIR>/settings/providers.json`.
|
||||
- User-added OpenAI-compatible provider model catalogs are persisted in `<CLINE_DATA_DIR>/settings/models.json` (or alongside `CLINE_PROVIDER_SETTINGS_PATH`).
|
||||
- `models.json` stores model lists by provider ID and is loaded by RPC runtime provider actions.
|
||||
- `models.json` stores model lists by provider ID and is loaded by the runtime provider actions.
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@clinebot/core": "workspace:*",
|
||||
"@clinebot/rpc": "workspace:*",
|
||||
"@clinebot/hub": "workspace:*",
|
||||
"@clinebot/enterprise": "workspace:*",
|
||||
"@clinebot/shared": "workspace:*",
|
||||
"@microsoft/tui-test": "^0.0.2",
|
||||
|
||||
@@ -517,7 +517,6 @@ export class AcpAgent implements Agent {
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
showUsage: false,
|
||||
showTimings: false,
|
||||
outputMode: "text",
|
||||
mode: session.currentMode,
|
||||
defaultToolAutoApprove: false,
|
||||
@@ -527,8 +526,6 @@ export class AcpAgent implements Agent {
|
||||
enableTools: true,
|
||||
cwd,
|
||||
workspaceRoot: resolveWorkspaceRoot(cwd),
|
||||
missionLogIntervalSteps: 3,
|
||||
missionLogIntervalMs: 120000,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ describe("cli e2e", () => {
|
||||
return {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
CLINE_DIR: path.join(homeDir, ".cline"),
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
|
||||
CLINE_SESSION_DATA_DIR: sessionDir,
|
||||
@@ -123,7 +124,6 @@ describe("cli e2e", () => {
|
||||
expect(asText(result.stderr)).toBe("");
|
||||
expect(asText(result.stdout)).toContain("Usage:");
|
||||
expect(asText(result.stdout)).toContain("--autoapprove [value]");
|
||||
expect(asText(result.stdout)).toContain("--auto-approve-all");
|
||||
expect(asText(result.stdout)).toContain("-T, --taskId <id>");
|
||||
expect(asText(result.stdout)).toContain("--sandbox");
|
||||
expect(asText(result.stdout)).toContain("--thinking");
|
||||
@@ -219,12 +219,10 @@ describe("cli e2e", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an error for unknown rpc subcommands", () => {
|
||||
const result = runCli(["rpc", "nonesuch"], { env: createIsolatedEnv() });
|
||||
it("returns an error for unknown hub subcommands", () => {
|
||||
const result = runCli(["hub", "nonesuch"], { env: createIsolatedEnv() });
|
||||
expect(result.status).toBe(1);
|
||||
expect(asText(result.stderr)).toContain(
|
||||
'unknown rpc subcommand "nonesuch"',
|
||||
);
|
||||
expect(asText(result.stderr)).toContain('unknown command "nonesuch"');
|
||||
});
|
||||
|
||||
it("returns an error for interactive auth when no TTY is available", () => {
|
||||
@@ -515,15 +513,14 @@ Skill from docs path.`,
|
||||
|
||||
it("lists configured agents with source paths", () => {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
|
||||
const workspace = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-workspace-"));
|
||||
tempDirs.push(homeDir, dataDir, workspace);
|
||||
const docsAgentsDir = path.join(homeDir, "Documents", "Cline", "Agents");
|
||||
const settingsAgentsDir = path.join(dataDir, "settings", "agents");
|
||||
mkdirSync(docsAgentsDir, { recursive: true });
|
||||
mkdirSync(settingsAgentsDir, { recursive: true });
|
||||
tempDirs.push(homeDir, workspace);
|
||||
const globalAgentsDir = path.join(homeDir, ".cline", "agents");
|
||||
const workspaceAgentsDir = path.join(workspace, ".cline", "agents");
|
||||
mkdirSync(globalAgentsDir, { recursive: true });
|
||||
mkdirSync(workspaceAgentsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(docsAgentsDir, "reviewer.yaml"),
|
||||
path.join(globalAgentsDir, "reviewer.yaml"),
|
||||
`---
|
||||
name: Reviewer
|
||||
description: Reviews code changes
|
||||
@@ -532,7 +529,7 @@ Review diffs thoroughly.`,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(settingsAgentsDir, "planner.yaml"),
|
||||
path.join(workspaceAgentsDir, "planner.yaml"),
|
||||
`---
|
||||
name: Planner
|
||||
description: Plans implementation tasks
|
||||
@@ -546,7 +543,7 @@ Break work into clear steps.`,
|
||||
env: {
|
||||
...createIsolatedEnv(),
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_DIR: path.join(homeDir, ".cline"),
|
||||
},
|
||||
});
|
||||
expect(textResult.status).toBe(0);
|
||||
@@ -554,10 +551,10 @@ Break work into clear steps.`,
|
||||
expect(asText(textResult.stdout)).toContain("Reviewer");
|
||||
expect(asText(textResult.stdout)).toContain("Planner");
|
||||
expect(asText(textResult.stdout)).toContain(
|
||||
path.join(docsAgentsDir, "reviewer.yaml"),
|
||||
path.join(globalAgentsDir, "reviewer.yaml"),
|
||||
);
|
||||
expect(asText(textResult.stdout)).toContain(
|
||||
path.join(settingsAgentsDir, "planner.yaml"),
|
||||
path.join(workspaceAgentsDir, "planner.yaml"),
|
||||
);
|
||||
|
||||
const jsonResult = runCli(["config", "agents", "--json"], {
|
||||
@@ -565,7 +562,7 @@ Break work into clear steps.`,
|
||||
env: {
|
||||
...createIsolatedEnv(),
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_DIR: path.join(homeDir, ".cline"),
|
||||
},
|
||||
});
|
||||
expect(jsonResult.status).toBe(0);
|
||||
@@ -577,7 +574,7 @@ Break work into clear steps.`,
|
||||
expect(parsed.some((agent) => agent.name === "Planner")).toBe(true);
|
||||
expect(
|
||||
parsed.some(
|
||||
(agent) => agent.path === path.join(docsAgentsDir, "reviewer.yaml"),
|
||||
(agent) => agent.path === path.join(globalAgentsDir, "reviewer.yaml"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -619,6 +616,7 @@ Break work into clear steps.`,
|
||||
env: {
|
||||
...createIsolatedEnv(),
|
||||
HOME: homeDir,
|
||||
CLINE_DIR: path.join(homeDir, ".cline"),
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
},
|
||||
});
|
||||
@@ -642,6 +640,7 @@ Break work into clear steps.`,
|
||||
env: {
|
||||
...createIsolatedEnv(),
|
||||
HOME: homeDir,
|
||||
CLINE_DIR: path.join(homeDir, ".cline"),
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
},
|
||||
});
|
||||
@@ -746,25 +745,115 @@ Break work into clear steps.`,
|
||||
});
|
||||
|
||||
it("lists available tools", () => {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
|
||||
const workspace = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-workspace-"));
|
||||
tempDirs.push(homeDir, dataDir, workspace);
|
||||
const workspacePluginsDir = path.join(workspace, ".cline", "plugins");
|
||||
const globalSettingsPath = path.join(
|
||||
dataDir,
|
||||
"settings",
|
||||
"global-settings.json",
|
||||
);
|
||||
mkdirSync(workspacePluginsDir, { recursive: true });
|
||||
mkdirSync(path.dirname(globalSettingsPath), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(workspacePluginsDir, "workspace-plugin.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" name: 'workspace-plugin',",
|
||||
" manifest: { capabilities: ['tools'] },",
|
||||
" setup(api) {",
|
||||
" api.registerTool({",
|
||||
" name: 'plugin_echo',",
|
||||
" description: 'Echo from plugin',",
|
||||
" inputSchema: { type: 'object', properties: {}, required: [] },",
|
||||
" execute: async () => ({ ok: true }),",
|
||||
" });",
|
||||
" },",
|
||||
"};",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
globalSettingsPath,
|
||||
JSON.stringify({ disabledTools: ["plugin_echo"] }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const textResult = runCli(["config", "tools"], {
|
||||
env: createIsolatedEnv(),
|
||||
cwd: workspace,
|
||||
env: {
|
||||
...createIsolatedEnv(),
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_GLOBAL_SETTINGS_PATH: globalSettingsPath,
|
||||
},
|
||||
});
|
||||
expect(textResult.status).toBe(0);
|
||||
expect(asText(textResult.stdout)).toContain("Available tools:");
|
||||
expect(asText(textResult.stdout)).toContain("read_files");
|
||||
expect(asText(textResult.stdout)).toContain(
|
||||
"read_files [default: enabled]",
|
||||
);
|
||||
expect(asText(textResult.stdout)).toContain(
|
||||
"spawn_agent [default: enabled]",
|
||||
);
|
||||
expect(asText(textResult.stdout)).toContain("teams [default: enabled]");
|
||||
expect(asText(textResult.stdout)).not.toContain("submit_and_exit");
|
||||
expect(asText(textResult.stdout)).not.toContain("apply_patch");
|
||||
expect(asText(textResult.stdout)).toContain("Plugin tools:");
|
||||
expect(asText(textResult.stdout)).toContain("plugin_echo");
|
||||
expect(asText(textResult.stdout)).toContain("[disabled]");
|
||||
|
||||
const jsonResult = runCli(["config", "tools", "--json"], {
|
||||
env: createIsolatedEnv(),
|
||||
cwd: workspace,
|
||||
env: {
|
||||
...createIsolatedEnv(),
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_GLOBAL_SETTINGS_PATH: globalSettingsPath,
|
||||
},
|
||||
});
|
||||
expect(jsonResult.status).toBe(0);
|
||||
const parsed = JSON.parse(asText(jsonResult.stdout)) as Array<{
|
||||
id?: string;
|
||||
name: string;
|
||||
type: string;
|
||||
enabled?: boolean;
|
||||
defaultEnabled?: boolean;
|
||||
headlessToolNames?: string[];
|
||||
}>;
|
||||
expect(parsed.some((tool) => tool.name === "run_commands")).toBe(true);
|
||||
expect(parsed.some((tool) => tool.name === "submit_and_exit")).toBe(false);
|
||||
expect(parsed.every((tool) => tool.type === "default")).toBe(true);
|
||||
expect(
|
||||
parsed.some(
|
||||
(tool) => tool.id === "run_commands" && tool.defaultEnabled === true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
parsed.some(
|
||||
(tool) =>
|
||||
tool.id === "editor" &&
|
||||
tool.headlessToolNames?.includes("editor") &&
|
||||
!tool.headlessToolNames?.includes("apply_patch"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
parsed.some(
|
||||
(tool) =>
|
||||
tool.id === "teams" &&
|
||||
tool.defaultEnabled === true &&
|
||||
tool.headlessToolNames?.includes("team_status"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(parsed.some((tool) => tool.id === "apply_patch")).toBe(false);
|
||||
expect(parsed.some((tool) => tool.id === "submit_and_exit")).toBe(false);
|
||||
expect(
|
||||
parsed.some(
|
||||
(tool) =>
|
||||
tool.name === "plugin_echo" &&
|
||||
tool.type === "plugin" &&
|
||||
tool.enabled === false,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid hook payloads", () => {
|
||||
|
||||
@@ -2,11 +2,12 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import {
|
||||
ALL_DEFAULT_TOOL_NAMES,
|
||||
type BuiltinToolAvailabilityContext,
|
||||
createUserInstructionConfigWatcher,
|
||||
discoverPluginModulePaths,
|
||||
hasMcpSettingsFile,
|
||||
listHookConfigFiles,
|
||||
listPluginTools,
|
||||
type RuleConfig,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
resolveMcpServerRegistrations,
|
||||
@@ -18,6 +19,8 @@ import {
|
||||
type WorkflowConfig,
|
||||
} from "@clinebot/core";
|
||||
import { Command } from "commander";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import { loadInteractiveConfigData } from "../tui/interactive-config";
|
||||
import type { CliOutputMode } from "../utils/types";
|
||||
|
||||
type ConfigIo = {
|
||||
@@ -25,15 +28,9 @@ type ConfigIo = {
|
||||
writeErr: (text: string) => void;
|
||||
};
|
||||
|
||||
const TOOL_NAMES = [...ALL_DEFAULT_TOOL_NAMES];
|
||||
|
||||
function resolveCliAgentConfigSearchPaths(): string[] {
|
||||
const clineDataDir =
|
||||
process.env.CLINE_DATA_DIR?.trim() || join(homedir(), ".cline", "data");
|
||||
return [
|
||||
join(homedir(), "Documents", "Cline", "Agents"),
|
||||
join(clineDataDir, "settings", "agents"),
|
||||
];
|
||||
function resolveCliAgentConfigSearchPaths(cwd: string): string[] {
|
||||
const clineDir = process.env.CLINE_DIR?.trim() || join(homedir(), ".cline");
|
||||
return [join(cwd, ".cline", "agents"), join(clineDir, "agents")];
|
||||
}
|
||||
|
||||
async function runWorkflowsConfigCommand(
|
||||
@@ -211,6 +208,7 @@ async function runSkillsConfigCommand(
|
||||
}
|
||||
|
||||
async function runAgentsConfigCommand(
|
||||
cwd: string,
|
||||
outputMode: CliOutputMode,
|
||||
io: ConfigIo,
|
||||
): Promise<number> {
|
||||
@@ -221,8 +219,8 @@ async function runAgentsConfigCommand(
|
||||
path: string;
|
||||
}
|
||||
>();
|
||||
const directories = resolveCliAgentConfigSearchPaths().filter((directory) =>
|
||||
existsSync(directory),
|
||||
const directories = resolveCliAgentConfigSearchPaths(cwd).filter(
|
||||
(directory) => existsSync(directory),
|
||||
);
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
@@ -390,29 +388,83 @@ async function runMcpConfigCommand(
|
||||
}
|
||||
|
||||
async function runToolsConfigCommand(
|
||||
cwd: string,
|
||||
outputMode: CliOutputMode,
|
||||
io: ConfigIo,
|
||||
availabilityContext?: BuiltinToolAvailabilityContext,
|
||||
): Promise<number> {
|
||||
const tools = TOOL_NAMES.map((name) => ({
|
||||
name,
|
||||
type: "default" as const,
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const tools = getToolCatalog(availabilityContext);
|
||||
const pluginTools = await listPluginTools({
|
||||
workspacePath: cwd,
|
||||
cwd,
|
||||
});
|
||||
|
||||
if (outputMode === "json") {
|
||||
process.stdout.write(JSON.stringify(tools));
|
||||
process.stdout.write(
|
||||
JSON.stringify([
|
||||
...tools,
|
||||
...pluginTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
type: "plugin" as const,
|
||||
pluginName: tool.pluginName,
|
||||
path: tool.path,
|
||||
source: tool.source,
|
||||
enabled: tool.enabled,
|
||||
description: tool.description,
|
||||
})),
|
||||
]),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (tools.length === 0) {
|
||||
if (tools.length === 0 && pluginTools.length === 0) {
|
||||
io.writeln("No tools found.");
|
||||
return 0;
|
||||
}
|
||||
io.writeln("Available tools:");
|
||||
for (const tool of tools) {
|
||||
io.writeln(` ${tool.name}`);
|
||||
const defaultState = tool.defaultEnabled ? "enabled" : "disabled";
|
||||
const names =
|
||||
tool.headlessToolNames.length === 1 &&
|
||||
tool.headlessToolNames[0] === tool.id
|
||||
? ""
|
||||
: ` -> ${tool.headlessToolNames.join(", ")}`;
|
||||
io.writeln(` ${tool.id} [default: ${defaultState}]${names}`);
|
||||
}
|
||||
if (pluginTools.length > 0) {
|
||||
io.writeln();
|
||||
io.writeln("Plugin tools:");
|
||||
for (const tool of pluginTools) {
|
||||
io.writeln(
|
||||
` ${tool.name} [plugin: ${tool.pluginName}] [${tool.enabled ? "enabled" : "disabled"}] (${tool.path})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function loadInteractiveConfigDataForCommand(
|
||||
cwd: string,
|
||||
): Promise<Awaited<ReturnType<typeof loadInteractiveConfigData>>> {
|
||||
const watcher = createUserInstructionConfigWatcher({
|
||||
skills: { workspacePath: cwd },
|
||||
rules: { workspacePath: cwd },
|
||||
workflows: { workspacePath: cwd },
|
||||
});
|
||||
try {
|
||||
await watcher.start();
|
||||
return await loadInteractiveConfigData({
|
||||
watcher,
|
||||
cwd,
|
||||
workspaceRoot: cwd,
|
||||
availabilityContext: {
|
||||
mode: "act",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
watcher.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export function createConfigCommand(
|
||||
getCwd: () => string,
|
||||
getOutputMode: () => CliOutputMode,
|
||||
@@ -430,6 +482,13 @@ export function createConfigCommand(
|
||||
.exitOverride()
|
||||
.action(async (target?: string) => {
|
||||
if (!target) {
|
||||
if (getOutputMode() === "json") {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(await loadInteractiveConfigDataForCommand(getCwd()))}\n`,
|
||||
);
|
||||
actionExitCode = 0;
|
||||
return;
|
||||
}
|
||||
actionExitCode = undefined;
|
||||
launchInteractiveConfigView();
|
||||
return;
|
||||
@@ -458,7 +517,11 @@ export function createConfigCommand(
|
||||
);
|
||||
break;
|
||||
case "agents":
|
||||
actionExitCode = await runAgentsConfigCommand(getOutputMode(), io);
|
||||
actionExitCode = await runAgentsConfigCommand(
|
||||
getCwd(),
|
||||
getOutputMode(),
|
||||
io,
|
||||
);
|
||||
break;
|
||||
case "plugins":
|
||||
actionExitCode = await runPluginsConfigCommand(
|
||||
@@ -478,7 +541,12 @@ export function createConfigCommand(
|
||||
actionExitCode = await runMcpConfigCommand(getOutputMode(), io);
|
||||
break;
|
||||
case "tools":
|
||||
actionExitCode = await runToolsConfigCommand(getOutputMode(), io);
|
||||
actionExitCode = await runToolsConfigCommand(
|
||||
getCwd(),
|
||||
getOutputMode(),
|
||||
io,
|
||||
{ mode: "act" },
|
||||
);
|
||||
break;
|
||||
default:
|
||||
io.writeErr(
|
||||
|
||||
@@ -3,12 +3,30 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockSpawnSync, mockGetRpcServerHealth, mockResolveClineDataDir } =
|
||||
vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
mockGetRpcServerHealth: vi.fn(),
|
||||
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
|
||||
}));
|
||||
const {
|
||||
mockSpawnSync,
|
||||
mockResolveClineDataDir,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockReadHubDiscovery,
|
||||
mockProbeHubServer,
|
||||
mockClearHubDiscovery,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
|
||||
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: path.join(
|
||||
"/tmp/cline-data",
|
||||
"locks",
|
||||
"hub",
|
||||
"owners",
|
||||
"hub-owner.json",
|
||||
),
|
||||
})),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawnSync: mockSpawnSync,
|
||||
@@ -16,12 +34,13 @@ vi.mock("node:child_process", () => ({
|
||||
|
||||
vi.mock("@clinebot/core", () => ({
|
||||
resolveClineDataDir: mockResolveClineDataDir,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
}));
|
||||
|
||||
vi.mock("@clinebot/rpc", () => ({
|
||||
RPC_BUILD_VERSION: "rpc-build-test",
|
||||
getRpcServerHealth: mockGetRpcServerHealth,
|
||||
getRpcServerDefaultAddress: vi.fn(() => "127.0.0.1:4317"),
|
||||
vi.mock("@clinebot/hub", () => ({
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/common", () => ({
|
||||
@@ -41,10 +60,17 @@ describe("runDoctorCommand", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not report rpc or hook worker processes as stale cli processes", async () => {
|
||||
mockGetRpcServerHealth.mockResolvedValue({
|
||||
running: true,
|
||||
serverId: "server-1",
|
||||
it("does not report hub processes as stale cli processes", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:4317/hub",
|
||||
port: 4317,
|
||||
pid: 50174,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:4317/hub",
|
||||
port: 4317,
|
||||
pid: 50174,
|
||||
});
|
||||
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
|
||||
if (command === "lsof") {
|
||||
@@ -62,29 +88,17 @@ describe("runDoctorCommand", () => {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: [
|
||||
"50174 /Users/example/.bun/bin/bun /Users/example/dev/sdk/apps/cli/src/index.ts rpc start --address 127.0.0.1:4317",
|
||||
"50181 /Users/example/.bun/bin/bun /Users/example/dev/sdk/apps/cli/src/index.ts hook-worker",
|
||||
"50174 /Users/example/.bun/bin/bun /Users/example/dev/sdk/apps/cli/src/index.ts hub start --cwd /workspace",
|
||||
"50190 /Users/example/.bun/bin/bun /Users/example/dev/sdk/apps/cli/src/index.ts hey",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-f" &&
|
||||
(args[1] === "hook-worker" || args[1] === " hook-worker ")
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: "50181\n",
|
||||
};
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ address: "127.0.0.1:4317", json: true },
|
||||
{ cwd, json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
@@ -99,65 +113,56 @@ describe("runDoctorCommand", () => {
|
||||
process.platform === "win32"
|
||||
? {
|
||||
listeningPids: [],
|
||||
rpcStartupLocks: [],
|
||||
rpcSpawnLeases: [],
|
||||
hubStartupLocks: [],
|
||||
staleCliPids: [],
|
||||
hookWorkerPids: [],
|
||||
}
|
||||
: {
|
||||
listeningPids: [50174],
|
||||
rpcStartupLocks: [],
|
||||
rpcSpawnLeases: [],
|
||||
hubStartupLocks: [],
|
||||
staleCliPids: [50190],
|
||||
hookWorkerPids: [50181],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("doctor --fix clears wedged rpc lock artifacts when no server is actually running", async () => {
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "doctor-rpc-fix-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
mockGetRpcServerHealth.mockResolvedValue(undefined);
|
||||
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
|
||||
tempDirs.push(cwd);
|
||||
const discoveryPath = path.join(cwd, ".hub-discovery.json");
|
||||
mockResolveSharedHubOwnerContext.mockReturnValue({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath,
|
||||
});
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:4317/hub",
|
||||
port: 4317,
|
||||
pid: 50000,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue(undefined);
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
|
||||
const startupLockDir = path.join(
|
||||
dataDir,
|
||||
"locks",
|
||||
"rpc-start-127.0.0.1_4317.lock",
|
||||
const startupLockDir = `${discoveryPath}.lock`;
|
||||
writeFileSync(
|
||||
discoveryPath,
|
||||
JSON.stringify({
|
||||
url: "ws://127.0.0.1:4317/hub",
|
||||
port: 4317,
|
||||
pid: 50000,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
mkdirSync(startupLockDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(startupLockDir, "owner.json"),
|
||||
JSON.stringify({
|
||||
address: "127.0.0.1:4317",
|
||||
pid: process.pid,
|
||||
acquiredAt: new Date().toISOString(),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const spawnLeasePath = path.join(
|
||||
dataDir,
|
||||
"sessions",
|
||||
"rpc",
|
||||
"spawn-leases",
|
||||
"MTI3LjAuMC4xOjQzMTc.lock",
|
||||
);
|
||||
mkdirSync(path.dirname(spawnLeasePath), { recursive: true });
|
||||
writeFileSync(
|
||||
spawnLeasePath,
|
||||
JSON.stringify({
|
||||
address: "127.0.0.1:4317",
|
||||
pid: process.pid,
|
||||
createdAt: Date.now(),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ address: "127.0.0.1:4317", json: true, fix: true },
|
||||
{ cwd, json: true, fix: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
@@ -170,18 +175,17 @@ describe("runDoctorCommand", () => {
|
||||
expect(output).toHaveLength(1);
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
killed: {
|
||||
rpcListeners: 0,
|
||||
hubListeners: 0,
|
||||
cliProcesses: 0,
|
||||
hookWorkers: 0,
|
||||
rpcStartupLocks: 1,
|
||||
rpcSpawnLeases: 1,
|
||||
hubStartupLocks: 1,
|
||||
hubDiscovery: 1,
|
||||
},
|
||||
after: {
|
||||
rpcHealthy: false,
|
||||
hubHealthy: false,
|
||||
listeningPids: [],
|
||||
rpcStartupLocks: [],
|
||||
rpcSpawnLeases: [],
|
||||
hubStartupLocks: [],
|
||||
},
|
||||
});
|
||||
expect(mockClearHubDiscovery).toHaveBeenCalledWith(discoveryPath);
|
||||
});
|
||||
});
|
||||
|
||||
+150
-258
@@ -1,8 +1,15 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolveClineDataDir } from "@clinebot/core";
|
||||
import { getRpcServerDefaultAddress, getRpcServerHealth } from "@clinebot/rpc";
|
||||
import {
|
||||
resolveClineDataDir,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "@clinebot/core";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
} from "@clinebot/hub";
|
||||
import { Command } from "commander";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
@@ -13,34 +20,20 @@ type DoctorIo = {
|
||||
writeErr: (text: string) => void;
|
||||
};
|
||||
|
||||
type DoctorStatus = {
|
||||
rpcAddress: string;
|
||||
rpcHealthy: boolean;
|
||||
rpcServerId?: string;
|
||||
listeningPids: number[];
|
||||
rpcStartupLocks: RpcStartupArtifact[];
|
||||
rpcSpawnLeases: RpcStartupArtifact[];
|
||||
staleCliPids: number[];
|
||||
hookWorkerPids: number[];
|
||||
activeConnectors: ActiveConnectorRecord[];
|
||||
recentSpawnedProcesses: SpawnedProcessRecord[];
|
||||
};
|
||||
|
||||
type RpcStartupArtifact = {
|
||||
type StartupArtifact = {
|
||||
path: string;
|
||||
address?: string;
|
||||
pid?: number;
|
||||
acquiredAt?: string;
|
||||
stale: boolean;
|
||||
};
|
||||
|
||||
type ActiveConnectorRecord = {
|
||||
type: string; //"telegram" | "gchat" | "whatsapp" | "linear" | "discord";
|
||||
type: string;
|
||||
pid: number;
|
||||
rpcAddress: string;
|
||||
hubUrl: string;
|
||||
startedAt?: string;
|
||||
applicationId?: string;
|
||||
botUsername?: string; // Username for Assistant Bots
|
||||
botUsername?: string;
|
||||
userName?: string;
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
@@ -55,6 +48,18 @@ type SpawnedProcessRecord = {
|
||||
detached?: boolean;
|
||||
};
|
||||
|
||||
type DoctorStatus = {
|
||||
cwd: string;
|
||||
hubUrl?: string;
|
||||
hubHealthy: boolean;
|
||||
hubPid?: number;
|
||||
listeningPids: number[];
|
||||
hubStartupLocks: StartupArtifact[];
|
||||
staleCliPids: number[];
|
||||
activeConnectors: ActiveConnectorRecord[];
|
||||
recentSpawnedProcesses: SpawnedProcessRecord[];
|
||||
};
|
||||
|
||||
type ProcessRecord = {
|
||||
pid: number;
|
||||
command: string;
|
||||
@@ -106,139 +111,8 @@ function resolveCliLogPath(): string {
|
||||
return join(resolveClineDataDir(), "logs", `${name}.log`);
|
||||
}
|
||||
|
||||
function parseRpcPort(address: string): number | undefined {
|
||||
const idx = address.lastIndexOf(":");
|
||||
if (idx <= 0 || idx >= address.length - 1) {
|
||||
return undefined;
|
||||
}
|
||||
const port = Number.parseInt(address.slice(idx + 1), 10);
|
||||
return Number.isInteger(port) && port > 0 ? port : undefined;
|
||||
}
|
||||
|
||||
function encodeRpcAddress(address: string): string {
|
||||
return Buffer.from(address).toString("base64url");
|
||||
}
|
||||
|
||||
function normalizeRpcAddressForLockName(address: string): string {
|
||||
return address.trim().replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
||||
}
|
||||
|
||||
function getRpcStartupLockRoot(): string {
|
||||
return join(resolveClineDataDir(), "locks");
|
||||
}
|
||||
|
||||
function getRpcStartupLockDir(address: string): string {
|
||||
return join(
|
||||
getRpcStartupLockRoot(),
|
||||
`rpc-start-${normalizeRpcAddressForLockName(address)}.lock`,
|
||||
);
|
||||
}
|
||||
|
||||
function getRpcSpawnLeaseRoot(): string {
|
||||
return join(resolveClineDataDir(), "sessions", "rpc", "spawn-leases");
|
||||
}
|
||||
|
||||
function getRpcSpawnLeasePath(address: string): string {
|
||||
return join(getRpcSpawnLeaseRoot(), `${encodeRpcAddress(address)}.lock`);
|
||||
}
|
||||
|
||||
function readRpcStartupArtifacts(path: string): RpcStartupArtifact | undefined {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(path, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const pid = typeof raw.pid === "number" ? raw.pid : undefined;
|
||||
const address = typeof raw.address === "string" ? raw.address : undefined;
|
||||
const acquiredAt =
|
||||
typeof raw.acquiredAt === "string"
|
||||
? raw.acquiredAt
|
||||
: typeof raw.createdAt === "number"
|
||||
? new Date(raw.createdAt).toISOString()
|
||||
: undefined;
|
||||
return {
|
||||
path,
|
||||
address,
|
||||
pid,
|
||||
acquiredAt,
|
||||
stale: !isProcessRunning(pid ?? -1),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
path,
|
||||
stale: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function listRpcStartupLocks(address: string): RpcStartupArtifact[] {
|
||||
const lockDir = getRpcStartupLockDir(address);
|
||||
if (!existsSync(lockDir)) {
|
||||
return [];
|
||||
}
|
||||
const ownerPath = join(lockDir, "owner.json");
|
||||
return [
|
||||
readRpcStartupArtifacts(ownerPath) ?? { path: ownerPath, stale: true },
|
||||
];
|
||||
}
|
||||
|
||||
function listRpcSpawnLeases(address: string): RpcStartupArtifact[] {
|
||||
const leasePath = getRpcSpawnLeasePath(address);
|
||||
if (!existsSync(leasePath)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
readRpcStartupArtifacts(leasePath) ?? { path: leasePath, stale: true },
|
||||
];
|
||||
}
|
||||
|
||||
function clearPathIfExists(path: string): boolean {
|
||||
if (!existsSync(path)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearRpcStartupArtifacts(
|
||||
address: string,
|
||||
options?: { forceAddressArtifacts?: boolean },
|
||||
): { startupLocks: number; spawnLeases: number } {
|
||||
const force = options?.forceAddressArtifacts === true;
|
||||
const startupLocks = listRpcStartupLocks(address);
|
||||
const spawnLeases = listRpcSpawnLeases(address);
|
||||
let clearedStartupLocks = 0;
|
||||
let clearedSpawnLeases = 0;
|
||||
|
||||
for (const artifact of startupLocks) {
|
||||
if (
|
||||
(force || artifact.stale) &&
|
||||
clearPathIfExists(dirname(artifact.path))
|
||||
) {
|
||||
clearedStartupLocks += 1;
|
||||
}
|
||||
}
|
||||
for (const artifact of spawnLeases) {
|
||||
if ((force || artifact.stale) && clearPathIfExists(artifact.path)) {
|
||||
clearedSpawnLeases += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
startupLocks: clearedStartupLocks,
|
||||
spawnLeases: clearedSpawnLeases,
|
||||
};
|
||||
}
|
||||
|
||||
function listListeningPids(address: string): number[] {
|
||||
const port = parseRpcPort(address);
|
||||
if (!port) {
|
||||
return [];
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
function listListeningPids(port: number | undefined): number[] {
|
||||
if (!port || process.platform === "win32") {
|
||||
return [];
|
||||
}
|
||||
const result = spawnSync("lsof", ["-nP", `-tiTCP:${port}`, "-sTCP:LISTEN"], {
|
||||
@@ -264,32 +138,11 @@ function listStaleCliPids(): number[] {
|
||||
}
|
||||
return [...records.values()]
|
||||
.filter(
|
||||
(record) =>
|
||||
!/(?:^|\s)(?:rpc|hook-worker|connect)(?:\s|$)/.test(record.command),
|
||||
(record) => !/(?:^|\s)(?:hub|rpc|connect)(?:\s|$)/.test(record.command),
|
||||
)
|
||||
.map((record) => record.pid);
|
||||
}
|
||||
|
||||
function listHookWorkerPids(): number[] {
|
||||
if (process.platform === "win32") {
|
||||
return [];
|
||||
}
|
||||
const patterns = ["hook-worker", " hook-worker "];
|
||||
const pids = new Set<number>();
|
||||
for (const pattern of patterns) {
|
||||
const result = spawnSync("pgrep", ["-f", pattern], { encoding: "utf8" });
|
||||
if (result.status !== 0 && result.status !== 1) {
|
||||
continue;
|
||||
}
|
||||
for (const pid of parsePids(result.stdout)) {
|
||||
if (pid !== process.pid && pid !== process.ppid) {
|
||||
pids.add(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...pids].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function readRecentSpawnedProcesses(limit = 20): SpawnedProcessRecord[] {
|
||||
const logPath = resolveCliLogPath();
|
||||
if (!existsSync(logPath)) {
|
||||
@@ -332,6 +185,73 @@ function readRecentSpawnedProcesses(limit = 20): SpawnedProcessRecord[] {
|
||||
}
|
||||
}
|
||||
|
||||
function readStartupArtifact(path: string): StartupArtifact | undefined {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(path, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const pid = typeof raw.pid === "number" ? raw.pid : undefined;
|
||||
const acquiredAt =
|
||||
typeof raw.acquiredAt === "string" ? raw.acquiredAt : undefined;
|
||||
return {
|
||||
path,
|
||||
pid,
|
||||
acquiredAt,
|
||||
stale: !isProcessRunning(pid ?? -1),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
path,
|
||||
stale: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function listHubStartupLocks(_cwd: string): StartupArtifact[] {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const ownerPath = join(`${owner.discoveryPath}.lock`, "owner.json");
|
||||
if (!existsSync(ownerPath)) {
|
||||
return [];
|
||||
}
|
||||
return [readStartupArtifact(ownerPath) ?? { path: ownerPath, stale: true }];
|
||||
}
|
||||
|
||||
function clearPathIfExists(path: string): boolean {
|
||||
if (!existsSync(path)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearHubStartupArtifacts(
|
||||
_cwd: string,
|
||||
options?: { clearDiscovery?: boolean },
|
||||
): Promise<{ startupLocks: number; discovery: number }> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const startupLocks = listHubStartupLocks(_cwd);
|
||||
let clearedStartupLocks = 0;
|
||||
for (const artifact of startupLocks) {
|
||||
if (artifact.stale && clearPathIfExists(dirname(artifact.path))) {
|
||||
clearedStartupLocks += 1;
|
||||
}
|
||||
}
|
||||
let clearedDiscovery = 0;
|
||||
if (options?.clearDiscovery && existsSync(owner.discoveryPath)) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
clearedDiscovery = 1;
|
||||
}
|
||||
return {
|
||||
startupLocks: clearedStartupLocks,
|
||||
discovery: clearedDiscovery,
|
||||
};
|
||||
}
|
||||
|
||||
function listConnectorStatePaths(
|
||||
type: ActiveConnectorRecord["type"],
|
||||
): string[] {
|
||||
@@ -362,16 +282,12 @@ function readJsonRecord(path: string): Record<string, unknown> | undefined {
|
||||
|
||||
type ConnectorFieldKey = keyof Omit<
|
||||
ActiveConnectorRecord,
|
||||
"type" | "pid" | "rpcAddress"
|
||||
"type" | "pid" | "hubUrl"
|
||||
>;
|
||||
|
||||
type ConnectorFieldExtractor = (
|
||||
p: Record<string, unknown>,
|
||||
) => string | number | undefined;
|
||||
|
||||
const connectorFieldExtractors: Record<
|
||||
ConnectorFieldKey,
|
||||
ConnectorFieldExtractor
|
||||
(p: Record<string, unknown>) => string | number | undefined
|
||||
> = {
|
||||
startedAt: (p) => (typeof p.startedAt === "string" ? p.startedAt : undefined),
|
||||
port: (p) => (typeof p.port === "number" ? p.port : undefined),
|
||||
@@ -385,12 +301,10 @@ const connectorFieldExtractors: Record<
|
||||
typeof p.phoneNumberId === "string" ? p.phoneNumberId : undefined,
|
||||
};
|
||||
|
||||
type ConnectorConfig = {
|
||||
required: ConnectorFieldKey[];
|
||||
optional: ConnectorFieldKey[];
|
||||
};
|
||||
|
||||
const connectorConfigs: Record<string, ConnectorConfig> = {
|
||||
const connectorConfigs: Record<
|
||||
string,
|
||||
{ required: ConnectorFieldKey[]; optional: ConnectorFieldKey[] }
|
||||
> = {
|
||||
discord: {
|
||||
required: ["userName", "applicationId"],
|
||||
optional: ["startedAt", "port", "baseUrl"],
|
||||
@@ -416,9 +330,13 @@ function readActiveConnectorRecord(
|
||||
return undefined;
|
||||
}
|
||||
const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
|
||||
const rpcAddress =
|
||||
typeof parsed.rpcAddress === "string" ? parsed.rpcAddress : undefined;
|
||||
if (!pid || !rpcAddress || !isProcessRunning(pid)) {
|
||||
const hubUrl =
|
||||
typeof parsed.hubUrl === "string"
|
||||
? parsed.hubUrl
|
||||
: typeof parsed.rpcAddress === "string"
|
||||
? parsed.rpcAddress
|
||||
: undefined;
|
||||
if (!pid || !hubUrl || !isProcessRunning(pid)) {
|
||||
return undefined;
|
||||
}
|
||||
const config = connectorConfigs[type];
|
||||
@@ -426,7 +344,7 @@ function readActiveConnectorRecord(
|
||||
return undefined;
|
||||
}
|
||||
const fields: Partial<
|
||||
Omit<ActiveConnectorRecord, "type" | "pid" | "rpcAddress">
|
||||
Omit<ActiveConnectorRecord, "type" | "pid" | "hubUrl">
|
||||
> = {};
|
||||
for (const key of config.required) {
|
||||
const value = connectorFieldExtractors[key](parsed);
|
||||
@@ -441,7 +359,7 @@ function readActiveConnectorRecord(
|
||||
(fields as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
return { type, pid, rpcAddress, ...fields } as ActiveConnectorRecord;
|
||||
return { type, pid, hubUrl, ...fields } as ActiveConnectorRecord;
|
||||
}
|
||||
|
||||
function listActiveConnectors(): ActiveConnectorRecord[] {
|
||||
@@ -470,17 +388,21 @@ function listActiveConnectors(): ActiveConnectorRecord[] {
|
||||
});
|
||||
}
|
||||
|
||||
async function collectDoctorStatus(address: string): Promise<DoctorStatus> {
|
||||
const health = await getRpcServerHealth(address);
|
||||
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url)
|
||||
: undefined;
|
||||
const current = health ?? discovery;
|
||||
return {
|
||||
rpcAddress: address,
|
||||
rpcHealthy: health?.running === true,
|
||||
rpcServerId: health?.serverId,
|
||||
listeningPids: listListeningPids(address),
|
||||
rpcStartupLocks: listRpcStartupLocks(address),
|
||||
rpcSpawnLeases: listRpcSpawnLeases(address),
|
||||
cwd,
|
||||
hubUrl: current?.url,
|
||||
hubHealthy: !!health?.url,
|
||||
hubPid: current?.pid,
|
||||
listeningPids: listListeningPids(current?.port),
|
||||
hubStartupLocks: listHubStartupLocks(cwd),
|
||||
staleCliPids: listStaleCliPids(),
|
||||
hookWorkerPids: listHookWorkerPids(),
|
||||
activeConnectors: listActiveConnectors(),
|
||||
recentSpawnedProcesses: readRecentSpawnedProcesses(),
|
||||
};
|
||||
@@ -517,7 +439,7 @@ function formatActiveConnector(record: ActiveConnectorRecord): string {
|
||||
record.type,
|
||||
identity,
|
||||
`pid=${record.pid}`,
|
||||
`rpc=${record.rpcAddress}`,
|
||||
`hub=${record.hubUrl}`,
|
||||
record.phoneNumberId ? `phone=${record.phoneNumberId}` : undefined,
|
||||
record.port ? `port=${record.port}` : undefined,
|
||||
record.baseUrl ? `url=${record.baseUrl}` : undefined,
|
||||
@@ -540,39 +462,31 @@ function killPids(pids: number[]): number {
|
||||
}
|
||||
|
||||
export async function runDoctorCommand(
|
||||
opts: { address: string; json?: boolean; fix?: boolean; verbose?: boolean },
|
||||
opts: { cwd: string; json?: boolean; fix?: boolean; verbose?: boolean },
|
||||
io: DoctorIo,
|
||||
): Promise<number> {
|
||||
const jsonOutput = opts.json === true;
|
||||
const fix = opts.fix === true;
|
||||
const verbose = opts.verbose === true;
|
||||
const address = opts.address;
|
||||
const before = await collectDoctorStatus(address);
|
||||
const before = await collectDoctorStatus(opts.cwd);
|
||||
|
||||
if (!fix) {
|
||||
if (jsonOutput) {
|
||||
io.writeln(JSON.stringify(before));
|
||||
return 0;
|
||||
}
|
||||
writeln(`rpc address ${c.dim}${before.rpcAddress}${c.reset}`);
|
||||
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
|
||||
writeln(
|
||||
`rpc healthy ${c.dim}${before.rpcHealthy ? "yes" : "no"}${before.rpcServerId ? ` (${before.rpcServerId})` : ""}${c.reset}`,
|
||||
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
|
||||
);
|
||||
writeln(formatPidList("rpc listeners", before.listeningPids));
|
||||
writeln(formatPidList("hub listeners", before.listeningPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"rpc startup locks",
|
||||
before.rpcStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
|
||||
),
|
||||
);
|
||||
writeln(
|
||||
formatPidList(
|
||||
"rpc spawn leases",
|
||||
before.rpcSpawnLeases.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
|
||||
"hub startup locks",
|
||||
before.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
|
||||
),
|
||||
);
|
||||
writeln(formatPidList("cli processes", before.staleCliPids));
|
||||
writeln(formatPidList("hook workers", before.hookWorkerPids));
|
||||
if (before.activeConnectors.length === 0) {
|
||||
writeln(`active connectors ${c.dim}0${c.reset}`);
|
||||
} else {
|
||||
@@ -587,11 +501,7 @@ export async function runDoctorCommand(
|
||||
writeln(`- ${c.dim}${formatRecentSpawnedProcess(record)}${c.reset}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
before.listeningPids.length > 0 ||
|
||||
before.staleCliPids.length > 0 ||
|
||||
before.hookWorkerPids.length > 0
|
||||
) {
|
||||
if (before.listeningPids.length > 0 || before.staleCliPids.length > 0) {
|
||||
io.writeln(
|
||||
"\nRun `cline doctor --fix` to kill all stale local processes.",
|
||||
);
|
||||
@@ -599,22 +509,17 @@ export async function runDoctorCommand(
|
||||
return 0;
|
||||
}
|
||||
|
||||
const killedRpc = killPids(before.listeningPids);
|
||||
const killedHub = killPids(before.listeningPids);
|
||||
const staleCliTargets = before.staleCliPids.filter(
|
||||
(pid) => !before.listeningPids.includes(pid),
|
||||
);
|
||||
const killedCli = killPids(staleCliTargets);
|
||||
const hookWorkerTargets = before.hookWorkerPids.filter(
|
||||
(pid) =>
|
||||
!before.listeningPids.includes(pid) && !staleCliTargets.includes(pid),
|
||||
);
|
||||
const killedHookWorkers = killPids(hookWorkerTargets);
|
||||
const postKillStatus = await collectDoctorStatus(address);
|
||||
const clearedArtifacts = clearRpcStartupArtifacts(address, {
|
||||
forceAddressArtifacts:
|
||||
!postKillStatus.rpcHealthy && postKillStatus.listeningPids.length === 0,
|
||||
const postKillStatus = await collectDoctorStatus(opts.cwd);
|
||||
const clearedArtifacts = await clearHubStartupArtifacts(opts.cwd, {
|
||||
clearDiscovery:
|
||||
!postKillStatus.hubHealthy && postKillStatus.listeningPids.length === 0,
|
||||
});
|
||||
const after = await collectDoctorStatus(address);
|
||||
const after = await collectDoctorStatus(opts.cwd);
|
||||
|
||||
if (jsonOutput) {
|
||||
io.writeln(
|
||||
@@ -622,41 +527,32 @@ export async function runDoctorCommand(
|
||||
before,
|
||||
after,
|
||||
killed: {
|
||||
rpcListeners: killedRpc,
|
||||
hubListeners: killedHub,
|
||||
cliProcesses: killedCli,
|
||||
hookWorkers: killedHookWorkers,
|
||||
rpcStartupLocks: clearedArtifacts.startupLocks,
|
||||
rpcSpawnLeases: clearedArtifacts.spawnLeases,
|
||||
hubStartupLocks: clearedArtifacts.startupLocks,
|
||||
hubDiscovery: clearedArtifacts.discovery,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
writeln(`killed rpc listeners ${c.dim}${killedRpc}${c.reset}`);
|
||||
writeln(`killed hub listeners ${c.dim}${killedHub}${c.reset}`);
|
||||
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
|
||||
writeln(`killed hook workers ${c.dim}${killedHookWorkers}${c.reset}`);
|
||||
writeln(
|
||||
`cleared rpc startup locks ${c.dim}${clearedArtifacts.startupLocks}${c.reset}`,
|
||||
`cleared hub startup locks ${c.dim}${clearedArtifacts.startupLocks}${c.reset}`,
|
||||
);
|
||||
writeln(
|
||||
`cleared rpc spawn leases ${c.dim}${clearedArtifacts.spawnLeases}${c.reset}`,
|
||||
`cleared hub discovery records ${c.dim}${clearedArtifacts.discovery}${c.reset}`,
|
||||
);
|
||||
writeln(`rpc healthy after fix: ${after.rpcHealthy ? "yes" : "no"}`);
|
||||
writeln(formatPidList("remaining rpc listeners", after.listeningPids));
|
||||
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
|
||||
writeln(formatPidList("remaining hub listeners", after.listeningPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining rpc startup locks",
|
||||
after.rpcStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
|
||||
),
|
||||
);
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining rpc spawn leases",
|
||||
after.rpcSpawnLeases.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
|
||||
"remaining hub startup locks",
|
||||
after.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
|
||||
),
|
||||
);
|
||||
writeln(formatPidList("remaining cli processes", after.staleCliPids));
|
||||
writeln(formatPidList("remaining hook workers", after.hookWorkerPids));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -667,17 +563,13 @@ export function createDoctorCommand(
|
||||
const doctor = new Command("doctor")
|
||||
.description("Diagnose and fix local process issues")
|
||||
.exitOverride()
|
||||
.option(
|
||||
"--address <host:port>",
|
||||
"RPC server address",
|
||||
process.env.CLINE_RPC_ADDRESS || getRpcServerDefaultAddress(),
|
||||
)
|
||||
.option("--cwd <path>", "Workspace root", process.cwd())
|
||||
.option("--json", "Output as JSON")
|
||||
.option("--fix", "Kill stale local processes")
|
||||
.option("-v, --verbose", "Show additional diagnostic details")
|
||||
.action(async function (this: Command) {
|
||||
const opts = this.opts<{
|
||||
address: string;
|
||||
cwd: string;
|
||||
json?: boolean;
|
||||
fix?: boolean;
|
||||
verbose?: boolean;
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("formatHistoryListLine", () => {
|
||||
checkpoint: {
|
||||
latest: {
|
||||
ref: "abc123",
|
||||
createdAt: 1_700_000_000_000,
|
||||
createdAt: 1767196800000,
|
||||
runCount: 3,
|
||||
},
|
||||
history: [
|
||||
@@ -49,7 +49,9 @@ describe("formatHistoryListLine", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(line).toContain("checkpoints:3 latest-run:3");
|
||||
expect(line).toContain(
|
||||
"12/31/2025 16:00 mock-provider:mock-model | $0.25 | hello world",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats a compact checkpoint badge summary in the line", () => {
|
||||
@@ -61,7 +63,7 @@ describe("formatHistoryListLine", () => {
|
||||
checkpoint: {
|
||||
latest: {
|
||||
ref: "abc123",
|
||||
createdAt: 1_700_000_000_000,
|
||||
createdAt: 1767196800000,
|
||||
runCount: 3,
|
||||
},
|
||||
history: [
|
||||
@@ -74,7 +76,10 @@ describe("formatHistoryListLine", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(line).toContain("checkpoints:3 latest-run:3");
|
||||
expect(line).toContain(
|
||||
"12/31/2025 16:00 mock-provider:mock-model | $0.25 | hello world",
|
||||
);
|
||||
expect(line).toMatch(/^\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it("formats checkpoint detail text for the selected row footer", () => {
|
||||
@@ -86,7 +91,7 @@ describe("formatHistoryListLine", () => {
|
||||
checkpoint: {
|
||||
latest: {
|
||||
ref: "abc123def4567890",
|
||||
createdAt: 1_700_000_000_000,
|
||||
createdAt: 1767196800000,
|
||||
runCount: 3,
|
||||
},
|
||||
history: [
|
||||
|
||||
@@ -104,6 +104,7 @@ async function runHistoryUpdate(
|
||||
export async function runHistoryList(input: {
|
||||
limit: number;
|
||||
outputMode: CliOutputMode;
|
||||
workspaceRoot?: string;
|
||||
io?: HistoryIo;
|
||||
}): Promise<number | string> {
|
||||
const io = input.io ?? {
|
||||
@@ -112,7 +113,9 @@ export async function runHistoryList(input: {
|
||||
};
|
||||
const limit = Number.isFinite(input.limit) ? input.limit : 200;
|
||||
|
||||
const hydratedRows = await listSessions(limit);
|
||||
const hydratedRows = await listSessions(limit, {
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
});
|
||||
if (hydratedRows.length === 0) {
|
||||
if (input.outputMode === "json") {
|
||||
process.stdout.write(JSON.stringify([]));
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import type { HookEventPayload, RunHookResult } from "@clinebot/core";
|
||||
import type { HookEventPayload } from "@clinebot/core";
|
||||
import { handleSessionHookEvent } from "../session/session";
|
||||
import {
|
||||
appendHookAudit,
|
||||
parseCliHookPayload,
|
||||
readStdinUtf8,
|
||||
truncate,
|
||||
writeHookJson,
|
||||
} from "../utils/helpers";
|
||||
|
||||
interface HookWorkerRequest {
|
||||
id: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
interface HookWorkerResponse {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
result?: RunHookResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function handleHookPayload(payload: HookEventPayload): Promise<unknown> {
|
||||
await appendHookAudit(payload);
|
||||
await handleSessionHookEvent(payload);
|
||||
@@ -43,27 +29,6 @@ async function handleHookPayload(payload: HookEventPayload): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function toHookResult(value: unknown): RunHookResult {
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
parsedJson: value,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkerRequest(raw: string): HookWorkerRequest {
|
||||
const parsed = JSON.parse(raw) as HookWorkerRequest;
|
||||
if (!parsed || typeof parsed.id !== "string" || !parsed.id.trim()) {
|
||||
throw new Error("invalid hook worker request id");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function encodeWorkerResponse(response: HookWorkerResponse): string {
|
||||
return `${JSON.stringify(response)}\n`;
|
||||
}
|
||||
|
||||
type HookIo = {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
@@ -91,79 +56,3 @@ export async function runHookCommand(io: HookIo) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runHookWorkerCommand(writeErr: (text: string) => void) {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
const raw = line.trim();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
let request: HookWorkerRequest;
|
||||
try {
|
||||
request = parseWorkerRequest(raw);
|
||||
} catch (error) {
|
||||
process.stdout.write(
|
||||
encodeWorkerResponse({
|
||||
id: "unknown",
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await parseCliHookPayload(request.payload);
|
||||
if (!payload) {
|
||||
throw new Error("invalid hook payload");
|
||||
}
|
||||
process.stdout.write(
|
||||
encodeWorkerResponse({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: toHookResult(await handleHookPayload(payload)),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
process.stdout.write(
|
||||
encodeWorkerResponse({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
writeErr(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function formatHookDispatchOutput(result?: RunHookResult): string {
|
||||
const value = result?.parsedJson;
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value as Record<string, unknown>).length === 0
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return truncate(value, 100);
|
||||
}
|
||||
return truncate(JSON.stringify(value), 100);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "@clinebot/core";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
ensureHubServer,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
} from "@clinebot/hub";
|
||||
import { Command } from "commander";
|
||||
|
||||
interface HubCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const pid = discovery?.pid;
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return !!pid;
|
||||
}
|
||||
|
||||
export function createHubCommand(
|
||||
io: HubCommandIo,
|
||||
setExitCode: (code: number) => void,
|
||||
): Command {
|
||||
let actionExitCode = 0;
|
||||
const fail = () => {
|
||||
actionExitCode = 1;
|
||||
};
|
||||
const action =
|
||||
<T extends unknown[]>(fn: (...args: T) => Promise<void>) =>
|
||||
async (...args: T) => {
|
||||
try {
|
||||
await fn(...args);
|
||||
} catch (error) {
|
||||
io.writeErr(error instanceof Error ? error.message : String(error));
|
||||
fail();
|
||||
}
|
||||
};
|
||||
|
||||
const hub = new Command("hub")
|
||||
.description("Manage the local hub daemon")
|
||||
.exitOverride()
|
||||
.hook("postAction", () => {
|
||||
setExitCode(actionExitCode);
|
||||
})
|
||||
.option("--cwd <path>", "Workspace root", process.cwd())
|
||||
.option("--host <host>", "Hub host")
|
||||
.option("--port <port>", "Hub port", (value) => Number.parseInt(value, 10))
|
||||
.option("--pathname <path>", "Hub websocket path");
|
||||
|
||||
hub.command("ensure").action(
|
||||
action(async () => {
|
||||
const opts = hub.opts<{
|
||||
cwd: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
}>();
|
||||
const result = await ensureHubServer({
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
io.writeln(result.url);
|
||||
}),
|
||||
);
|
||||
|
||||
hub.command("start").action(
|
||||
action(async () => {
|
||||
const opts = hub.opts<{
|
||||
cwd: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
}>();
|
||||
const result = await ensureHubServer({
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
io.writeln(result.url);
|
||||
if (!result.server) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const shutdown = () => resolve();
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
});
|
||||
await result.server.close();
|
||||
}),
|
||||
);
|
||||
|
||||
hub.command("status").action(
|
||||
action(async () => {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url)
|
||||
: undefined;
|
||||
io.writeln(
|
||||
JSON.stringify({
|
||||
running: !!health?.url,
|
||||
url: health?.url,
|
||||
pid: health?.pid,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
hub.command("stop").action(
|
||||
action(async () => {
|
||||
const opts = hub.opts<{ cwd: string }>();
|
||||
const stopped = await stopHubServer(opts.cwd);
|
||||
io.writeln(JSON.stringify({ stopped }));
|
||||
}),
|
||||
);
|
||||
|
||||
return hub;
|
||||
}
|
||||
@@ -4,22 +4,6 @@ import type { ParsedArgs } from "../utils/types";
|
||||
|
||||
export { CommanderError };
|
||||
|
||||
/**
|
||||
* Collect repeatable option values into an array.
|
||||
*/
|
||||
function collect(value: string, previous: string[]): string[] {
|
||||
return previous.concat(value);
|
||||
}
|
||||
|
||||
function expandToolOptionValues(values: string[]): string[] {
|
||||
return values.flatMap((value) =>
|
||||
value
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAutoApproveValue(
|
||||
value: string | boolean | undefined,
|
||||
): string {
|
||||
@@ -36,13 +20,9 @@ export function addRootOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.option(
|
||||
"--acp",
|
||||
"[TODO] Run in ACP (Agent Client Protocol) mode for editor integration",
|
||||
"Run in ACP (Agent Client Protocol) mode for editor integration",
|
||||
)
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option(
|
||||
"--auto-approve-all",
|
||||
"Enable auto-approve all actions while keeping interactive mode",
|
||||
)
|
||||
.option(
|
||||
"--autoapprove [value]",
|
||||
"Set tool auto-approval for all tools (`true` or `false`)",
|
||||
@@ -50,7 +30,6 @@ export function addRootOptions(cmd: Command): Command {
|
||||
)
|
||||
.option("--config <dir>", "Configuration directory")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--enable-spawn") // alias for --spawn
|
||||
.option(
|
||||
"--hooks-dir <dir>",
|
||||
"Path to additional hooks directory for runtime hook injection",
|
||||
@@ -64,14 +43,6 @@ export function addRootOptions(cmd: Command): Command {
|
||||
"Maximum consecutive mistakes before halting in yolo mode",
|
||||
)
|
||||
.option("-n, --max-iterations <count>")
|
||||
.option(
|
||||
"--mission-step-interval <count>",
|
||||
"Mission log update cadence in meaningful steps",
|
||||
)
|
||||
.option(
|
||||
"--mission-time-interval-ms <ms>",
|
||||
"Mission log update cadence in milliseconds",
|
||||
)
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-P, --provider <id>", "Provider id (default: cline)")
|
||||
@@ -85,23 +56,14 @@ export function addRootOptions(cmd: Command): Command {
|
||||
"--sandbox-dir <dir>",
|
||||
"Sandbox state dir (default: $CLINE_SANDBOX_DATA_DIR or /tmp/cline-sandbox)",
|
||||
)
|
||||
.option("--spawn", undefined, true)
|
||||
.option("--no-spawn", "Disable spawn_agent")
|
||||
.option("-s, --system <prompt>", "Override the system prompt")
|
||||
.option("-T, --taskId <id>", "Resume an existing task by ID")
|
||||
.option("--team-name <name>", "Override the runtime team state name")
|
||||
.option("--teams", undefined, true)
|
||||
.option("--no-teams", "Disable agent-team tools")
|
||||
.option("--thinking", "Enable extended thinking (default: medium effort)")
|
||||
.option(
|
||||
"-t, --timeout <seconds>",
|
||||
"Optional timeout in seconds (applies only when provided)",
|
||||
)
|
||||
.option("--timings", "Show timing details")
|
||||
.option("--tool-disable <name>", "Explicitly disable one tool", collect, [])
|
||||
.option("--tool-enable <name>", "Explicitly enable one tool", collect, [])
|
||||
.option("--tools", undefined, true)
|
||||
.option("--no-tools", "Disable tools")
|
||||
.option("-u, --usage", "Show token usage and estimated cost")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)");
|
||||
@@ -128,14 +90,11 @@ export function createProgram(): Command {
|
||||
|
||||
export function commanderToParsedArgs(program: Command): ParsedArgs {
|
||||
const opts = program.opts();
|
||||
const spawnValueSource = program.getOptionValueSource("spawn");
|
||||
const teamsValueSource = program.getOptionValueSource("teams");
|
||||
|
||||
const result: ParsedArgs = {
|
||||
verbose: !!opts.verbose,
|
||||
interactive: !!opts.interactive,
|
||||
showUsage: !!opts.usage,
|
||||
showTimings: !!opts.timings,
|
||||
outputMode: opts.json ? "json" : "text",
|
||||
mode: opts.plan ? "plan" : "act",
|
||||
yolo: opts.yolo ?? false,
|
||||
@@ -144,27 +103,9 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
|
||||
thinking: !!opts.thinking,
|
||||
reasoningEffort: undefined,
|
||||
liveModelCatalog: !!opts.refreshModels,
|
||||
enableSpawnAgent: opts.enableSpawn ? true : opts.spawn,
|
||||
enableAgentTeams: opts.teams,
|
||||
enableTools: opts.tools,
|
||||
defaultToolAutoApprove: true,
|
||||
toolPolicies: {},
|
||||
};
|
||||
|
||||
// --enable-spawn overrides --spawn/--no-spawn
|
||||
if (opts.enableSpawn) {
|
||||
result.enableSpawnAgent = true;
|
||||
}
|
||||
|
||||
if (opts.yolo) {
|
||||
if (!opts.enableSpawn && spawnValueSource === "default") {
|
||||
result.enableSpawnAgent = false;
|
||||
}
|
||||
if (teamsValueSource === "default") {
|
||||
result.enableAgentTeams = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Approval: last-wins semantics
|
||||
if (opts.autoapprove !== undefined) {
|
||||
const raw = String(opts.autoapprove).trim().toLowerCase();
|
||||
@@ -176,7 +117,7 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
|
||||
result.invalidAutoApprove = raw;
|
||||
}
|
||||
}
|
||||
if (opts.autoApproveAll || opts.yolo) {
|
||||
if (opts.yolo) {
|
||||
result.defaultToolAutoApprove = true;
|
||||
}
|
||||
|
||||
@@ -233,35 +174,6 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
|
||||
if (opts.maxIterations !== undefined) {
|
||||
result.maxIterations = Number.parseInt(opts.maxIterations, 10);
|
||||
}
|
||||
if (opts.missionStepInterval !== undefined) {
|
||||
result.missionLogIntervalSteps = Number.parseInt(
|
||||
opts.missionStepInterval,
|
||||
10,
|
||||
);
|
||||
}
|
||||
if (opts.missionTimeIntervalMs !== undefined) {
|
||||
result.missionLogIntervalMs = Number.parseInt(
|
||||
opts.missionTimeIntervalMs,
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
// Tool policies
|
||||
const toolEnable = expandToolOptionValues(opts.toolEnable ?? []);
|
||||
const toolDisable = expandToolOptionValues(opts.toolDisable ?? []);
|
||||
|
||||
for (const name of toolEnable) {
|
||||
result.toolPolicies[name] = {
|
||||
...(result.toolPolicies[name] ?? {}),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
for (const name of toolDisable) {
|
||||
result.toolPolicies[name] = {
|
||||
...(result.toolPolicies[name] ?? {}),
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Positional args → prompt
|
||||
const positional = program.args.filter((a) => !a.startsWith("-"));
|
||||
|
||||
@@ -1,570 +0,0 @@
|
||||
import {
|
||||
type AgentHooks,
|
||||
CoreSessionService,
|
||||
createPersistentSubprocessHooks,
|
||||
type Llms,
|
||||
LocalRuntimeHost,
|
||||
type PersistentSubprocessHookControl,
|
||||
SqliteSessionStore,
|
||||
} from "@clinebot/core";
|
||||
import { type RpcRuntimeHandlers, RpcSessionClient } from "@clinebot/rpc";
|
||||
import {
|
||||
CLINE_DEFAULT_RPC_ADDRESS,
|
||||
createSessionId,
|
||||
type HookSessionContext,
|
||||
type HookSessionContextLookup,
|
||||
} from "@clinebot/shared";
|
||||
import {
|
||||
createCliLoggerAdapter,
|
||||
flushCliLoggerAdapters,
|
||||
} from "../logging/adapter";
|
||||
import { logSpawnedProcess } from "../logging/process";
|
||||
import { createCliMessagesArtifactUploader } from "../utils/enterprise";
|
||||
import {
|
||||
buildCliSubcommandCommand,
|
||||
buildInternalCliEnv,
|
||||
} from "../utils/internal-launch";
|
||||
import {
|
||||
createRpcToolApprovalRequester,
|
||||
subscribeRuntimeEventBridge,
|
||||
} from "./rpc-runtime/event-bridge";
|
||||
import {
|
||||
runProviderAction,
|
||||
runProviderOAuthLogin,
|
||||
} from "./rpc-runtime/provider-actions";
|
||||
import {
|
||||
applyHomeDir,
|
||||
buildSessionStartInput,
|
||||
cleanupMaterializedFiles,
|
||||
materializeUserFiles,
|
||||
parseSendPayload,
|
||||
parseStartPayload,
|
||||
shouldRestoreSession,
|
||||
toRpcTurnResult,
|
||||
} from "./rpc-runtime/session-helpers";
|
||||
|
||||
const RPC_RUNTIME_NAME = "rpc-runtime";
|
||||
const moduleLogger = createCliLoggerAdapter({
|
||||
runtime: RPC_RUNTIME_NAME,
|
||||
component: "rpc-runtime",
|
||||
}).core;
|
||||
|
||||
type HookRunStartContext = Parameters<NonNullable<AgentHooks["onRunStart"]>>[0];
|
||||
type HookSessionShutdownContext = Parameters<
|
||||
NonNullable<AgentHooks["onSessionShutdown"]>
|
||||
>[0];
|
||||
|
||||
function getHookWorkerCommand(): string[] | undefined {
|
||||
const command = buildCliSubcommandCommand("hook-worker");
|
||||
return command ? [command.launcher, ...command.childArgs] : undefined;
|
||||
}
|
||||
|
||||
class RpcRuntimeHookService {
|
||||
private readonly logger = createCliLoggerAdapter({
|
||||
runtime: RPC_RUNTIME_NAME,
|
||||
component: "hooks",
|
||||
}).core;
|
||||
private readonly agentRoots = new Map<string, string>();
|
||||
private readonly conversationRoots = new Map<string, string>();
|
||||
private readonly rootMembers = new Map<
|
||||
string,
|
||||
{ agents: Set<string>; conversations: Set<string> }
|
||||
>();
|
||||
private readonly control?: PersistentSubprocessHookControl;
|
||||
public readonly hooks?: AgentHooks;
|
||||
|
||||
constructor(rpcAddress: string) {
|
||||
const command = getHookWorkerCommand();
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
this.control = createPersistentSubprocessHooks({
|
||||
command,
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...buildInternalCliEnv("hook-worker"),
|
||||
CLINE_RPC_ADDRESS: rpcAddress,
|
||||
CLINE_SESSION_BACKEND_MODE: "rpc",
|
||||
},
|
||||
sessionContext: (input) => this.resolveSessionContext(input),
|
||||
onDispatchError: (error, payload) => {
|
||||
this.logger.log("RPC hook dispatch failed", {
|
||||
severity: "warn",
|
||||
error,
|
||||
hookName: payload.hookName,
|
||||
taskId: payload.taskId,
|
||||
agentId: payload.agent_id,
|
||||
});
|
||||
},
|
||||
onSpawn: ({ command: spawnedCommand, pid, detached }) => {
|
||||
logSpawnedProcess({
|
||||
component: "hooks",
|
||||
command: spawnedCommand,
|
||||
childPid: pid,
|
||||
detached,
|
||||
cwd: process.cwd(),
|
||||
metadata: { runtime: RPC_RUNTIME_NAME },
|
||||
});
|
||||
},
|
||||
});
|
||||
this.hooks = this.wrapHooks(this.control.hooks);
|
||||
}
|
||||
|
||||
public registerSession(sessionId: string): void {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
if (!normalizedSessionId) {
|
||||
return;
|
||||
}
|
||||
this.conversationRoots.set(normalizedSessionId, normalizedSessionId);
|
||||
this.membersForRoot(normalizedSessionId).conversations.add(
|
||||
normalizedSessionId,
|
||||
);
|
||||
}
|
||||
|
||||
public unregisterSession(sessionId: string): void {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
if (!normalizedSessionId) {
|
||||
return;
|
||||
}
|
||||
this.clearRoot(normalizedSessionId);
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
this.agentRoots.clear();
|
||||
this.conversationRoots.clear();
|
||||
this.rootMembers.clear();
|
||||
await this.control?.client.close();
|
||||
}
|
||||
|
||||
private wrapHooks(hooks: AgentHooks): AgentHooks {
|
||||
return {
|
||||
...hooks,
|
||||
onSessionStart: async (ctx) => {
|
||||
this.trackContext(ctx);
|
||||
return await hooks.onSessionStart?.(ctx);
|
||||
},
|
||||
onRunStart: async (ctx) => {
|
||||
this.trackContext(ctx);
|
||||
return await hooks.onRunStart?.(ctx);
|
||||
},
|
||||
onSessionShutdown: async (ctx) => {
|
||||
this.trackContext(ctx);
|
||||
try {
|
||||
return await hooks.onSessionShutdown?.(ctx);
|
||||
} finally {
|
||||
this.releaseContext(ctx);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private resolveSessionContext(
|
||||
input?: HookSessionContextLookup,
|
||||
): HookSessionContext | undefined {
|
||||
const rootSessionId = this.resolveRootSessionId(input);
|
||||
if (!rootSessionId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
rootSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveRootSessionId(
|
||||
input?: HookSessionContextLookup,
|
||||
): string | undefined {
|
||||
const conversationId = input?.conversationId?.trim();
|
||||
const agentId = input?.agentId?.trim();
|
||||
const parentAgentId = input?.parentAgentId?.trim();
|
||||
if (conversationId) {
|
||||
const rootFromConversation = this.conversationRoots.get(conversationId);
|
||||
if (rootFromConversation) {
|
||||
return rootFromConversation;
|
||||
}
|
||||
if (this.conversationRoots.has(conversationId)) {
|
||||
return conversationId;
|
||||
}
|
||||
}
|
||||
if (agentId) {
|
||||
const rootFromAgent = this.agentRoots.get(agentId);
|
||||
if (rootFromAgent) {
|
||||
return rootFromAgent;
|
||||
}
|
||||
}
|
||||
if (parentAgentId) {
|
||||
return this.agentRoots.get(parentAgentId);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private trackContext(
|
||||
ctx: Pick<
|
||||
HookRunStartContext,
|
||||
"agentId" | "conversationId" | "parentAgentId"
|
||||
>,
|
||||
): void {
|
||||
const agentId = ctx.agentId.trim();
|
||||
const conversationId = ctx.conversationId.trim();
|
||||
const rootSessionId =
|
||||
this.resolveRootSessionId({
|
||||
agentId,
|
||||
conversationId,
|
||||
parentAgentId: ctx.parentAgentId,
|
||||
}) ?? (!ctx.parentAgentId ? conversationId : undefined);
|
||||
if (!rootSessionId) {
|
||||
return;
|
||||
}
|
||||
if (agentId) {
|
||||
this.agentRoots.set(agentId, rootSessionId);
|
||||
this.membersForRoot(rootSessionId).agents.add(agentId);
|
||||
}
|
||||
if (conversationId) {
|
||||
this.conversationRoots.set(conversationId, rootSessionId);
|
||||
this.membersForRoot(rootSessionId).conversations.add(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
private releaseContext(ctx: HookSessionShutdownContext): void {
|
||||
const agentId = ctx.agentId.trim();
|
||||
const conversationId = ctx.conversationId.trim();
|
||||
const rootSessionId =
|
||||
this.resolveRootSessionId({
|
||||
agentId,
|
||||
conversationId,
|
||||
parentAgentId: ctx.parentAgentId,
|
||||
}) ?? conversationId;
|
||||
if (!rootSessionId) {
|
||||
return;
|
||||
}
|
||||
if (ctx.parentAgentId) {
|
||||
if (agentId) {
|
||||
this.agentRoots.delete(agentId);
|
||||
this.rootMembers.get(rootSessionId)?.agents.delete(agentId);
|
||||
}
|
||||
if (conversationId) {
|
||||
this.conversationRoots.delete(conversationId);
|
||||
this.rootMembers
|
||||
.get(rootSessionId)
|
||||
?.conversations.delete(conversationId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.clearRoot(rootSessionId);
|
||||
}
|
||||
|
||||
private membersForRoot(rootSessionId: string): {
|
||||
agents: Set<string>;
|
||||
conversations: Set<string>;
|
||||
} {
|
||||
let members = this.rootMembers.get(rootSessionId);
|
||||
if (!members) {
|
||||
members = {
|
||||
agents: new Set<string>(),
|
||||
conversations: new Set<string>(),
|
||||
};
|
||||
this.rootMembers.set(rootSessionId, members);
|
||||
}
|
||||
return members;
|
||||
}
|
||||
|
||||
private clearRoot(rootSessionId: string): void {
|
||||
const members = this.rootMembers.get(rootSessionId);
|
||||
if (members) {
|
||||
for (const agentId of members.agents) {
|
||||
this.agentRoots.delete(agentId);
|
||||
}
|
||||
for (const conversationId of members.conversations) {
|
||||
this.conversationRoots.delete(conversationId);
|
||||
}
|
||||
this.rootMembers.delete(rootSessionId);
|
||||
return;
|
||||
}
|
||||
this.conversationRoots.delete(rootSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export function createRpcRuntimeHandlers(): RpcRuntimeHandlers {
|
||||
const RPC_SESSION_COMPONENT = "rpc-runtime-session";
|
||||
const processId = process.pid.toString();
|
||||
const sessionManager = new LocalRuntimeHost({
|
||||
sessionService: new CoreSessionService(new SqliteSessionStore(), {
|
||||
messagesArtifactUploader: createCliMessagesArtifactUploader(),
|
||||
}),
|
||||
});
|
||||
const sessionModes = new Map<string, "act" | "plan" | "yolo">();
|
||||
const activeSessions = new Set<string>();
|
||||
const rpcAddress =
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() || CLINE_DEFAULT_RPC_ADDRESS;
|
||||
const hookService = new RpcRuntimeHookService(rpcAddress);
|
||||
const eventClient = new RpcSessionClient({ address: rpcAddress });
|
||||
const runtimeClientId = `cli-rpc-runtime-${processId}`;
|
||||
const unsubscribeEventBridge = subscribeRuntimeEventBridge({
|
||||
sessionManager,
|
||||
eventClient,
|
||||
});
|
||||
const cleanupFailedSession = async (
|
||||
sessionId: string,
|
||||
runtimeLogger: ReturnType<typeof createCliLoggerAdapter>["core"],
|
||||
reason: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await sessionManager.stop(sessionId);
|
||||
} catch (stopError) {
|
||||
runtimeLogger.log("RPC runtime failed-session cleanup errored", {
|
||||
severity: "warn",
|
||||
sessionId,
|
||||
reason,
|
||||
error: stopError,
|
||||
});
|
||||
} finally {
|
||||
activeSessions.delete(sessionId);
|
||||
sessionModes.delete(sessionId);
|
||||
hookService.unregisterSession(sessionId);
|
||||
}
|
||||
};
|
||||
const stopTrackedSessions = async (
|
||||
shutdownReason: "rpc_runtime_dispose" | "rpc_runtime_shutdown",
|
||||
): Promise<void> => {
|
||||
const sessionIds = [...activeSessions];
|
||||
await Promise.allSettled(
|
||||
sessionIds.map(async (sessionId) => {
|
||||
try {
|
||||
await sessionManager.abort(
|
||||
sessionId,
|
||||
new Error(`RPC runtime abort during ${shutdownReason}`),
|
||||
);
|
||||
} catch {
|
||||
// Best-effort abort before stop.
|
||||
}
|
||||
try {
|
||||
await sessionManager.stop(sessionId);
|
||||
} catch {
|
||||
// Best-effort stop during runtime teardown.
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (shutdownReason === "rpc_runtime_shutdown") {
|
||||
activeSessions.clear();
|
||||
sessionModes.clear();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
startSession: async (request) => {
|
||||
const config = parseStartPayload(request);
|
||||
applyHomeDir(config);
|
||||
const runtimeLogger = createCliLoggerAdapter({
|
||||
runtime: RPC_RUNTIME_NAME,
|
||||
component: RPC_SESSION_COMPONENT,
|
||||
runtimeConfig: config.logger,
|
||||
}).core;
|
||||
const sessionId = config.sessionId?.trim() || createSessionId();
|
||||
const startedConfig = await buildSessionStartInput({
|
||||
config,
|
||||
sessionId,
|
||||
initialMessages: config.initialMessages as Llms.Message[] | undefined,
|
||||
hooks: hookService.hooks,
|
||||
});
|
||||
startedConfig.sessionInput.requestToolApproval =
|
||||
createRpcToolApprovalRequester({
|
||||
eventClient,
|
||||
runtimeClientId,
|
||||
sessionId,
|
||||
});
|
||||
const started = await sessionManager.start({
|
||||
...startedConfig.sessionInput,
|
||||
config: {
|
||||
...startedConfig.sessionInput.config,
|
||||
mode: startedConfig.sessionInput.config.mode ?? "act",
|
||||
},
|
||||
});
|
||||
runtimeLogger.log("RPC runtime session started", {
|
||||
sessionId: started.sessionId,
|
||||
mode: startedConfig.mode,
|
||||
});
|
||||
hookService.registerSession(started.sessionId);
|
||||
sessionModes.set(started.sessionId, startedConfig.mode);
|
||||
activeSessions.add(started.sessionId);
|
||||
return {
|
||||
sessionId: started.sessionId,
|
||||
startResult: {
|
||||
sessionId: started.sessionId,
|
||||
manifestPath: started.manifestPath,
|
||||
messagesPath: started.messagesPath,
|
||||
},
|
||||
};
|
||||
},
|
||||
sendSession: async (sessionId, requestInput) => {
|
||||
moduleLogger.debug("sendSession called", {
|
||||
sessionId,
|
||||
activeSessions: [...activeSessions],
|
||||
});
|
||||
const request = parseSendPayload(requestInput);
|
||||
applyHomeDir(request.config);
|
||||
const runtimeLogger = createCliLoggerAdapter({
|
||||
runtime: RPC_RUNTIME_NAME,
|
||||
component: RPC_SESSION_COMPONENT,
|
||||
runtimeConfig: request.config.logger,
|
||||
}).core;
|
||||
const input = request.prompt.trim();
|
||||
const userImages = request.attachments?.userImages ?? [];
|
||||
const fileMaterialized = await materializeUserFiles(
|
||||
request.attachments?.userFiles,
|
||||
);
|
||||
|
||||
try {
|
||||
runtimeLogger.debug("RPC runtime turn send requested", {
|
||||
sessionId,
|
||||
promptLength: input.length,
|
||||
});
|
||||
const result = await sessionManager.send({
|
||||
sessionId,
|
||||
prompt: input,
|
||||
userImages,
|
||||
userFiles: fileMaterialized.paths,
|
||||
delivery: request.delivery,
|
||||
});
|
||||
if (!result) {
|
||||
return { queued: true };
|
||||
}
|
||||
runtimeLogger.log("RPC runtime turn send completed", {
|
||||
sessionId,
|
||||
finishReason: result.finishReason,
|
||||
iterations: result.iterations,
|
||||
});
|
||||
return { result: toRpcTurnResult(result) };
|
||||
} catch (error) {
|
||||
if (!shouldRestoreSession(error)) {
|
||||
runtimeLogger.error?.("RPC runtime turn send failed", { error });
|
||||
await cleanupFailedSession(
|
||||
sessionId,
|
||||
runtimeLogger,
|
||||
"send_failed_non_restorable",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const restoredConfig = await buildSessionStartInput({
|
||||
config: request.config,
|
||||
sessionId,
|
||||
initialMessages: request.messages as unknown as
|
||||
| Llms.Message[]
|
||||
| undefined,
|
||||
hooks: hookService.hooks,
|
||||
});
|
||||
const restoredSessionConfig = restoredConfig.sessionInput.config;
|
||||
const restoredStarted = await sessionManager.start({
|
||||
...restoredConfig.sessionInput,
|
||||
config: {
|
||||
...restoredSessionConfig,
|
||||
mode: restoredSessionConfig.mode ?? "act",
|
||||
} as typeof restoredSessionConfig & {
|
||||
mode: NonNullable<typeof restoredSessionConfig.mode>;
|
||||
},
|
||||
});
|
||||
hookService.registerSession(restoredStarted.sessionId);
|
||||
runtimeLogger.log(
|
||||
"RPC runtime session restored after missing session",
|
||||
{
|
||||
severity: "warn",
|
||||
sessionId,
|
||||
},
|
||||
);
|
||||
sessionModes.set(sessionId, restoredConfig.mode);
|
||||
activeSessions.add(sessionId);
|
||||
const restoredResult = await (async () => {
|
||||
try {
|
||||
return await sessionManager.send({
|
||||
sessionId,
|
||||
prompt: input,
|
||||
userImages,
|
||||
userFiles: fileMaterialized.paths,
|
||||
delivery: request.delivery,
|
||||
});
|
||||
} catch (restoredError) {
|
||||
runtimeLogger.error?.(
|
||||
"RPC runtime turn send failed after restore",
|
||||
{
|
||||
error: restoredError,
|
||||
},
|
||||
);
|
||||
await cleanupFailedSession(
|
||||
sessionId,
|
||||
runtimeLogger,
|
||||
"send_failed_after_restore",
|
||||
);
|
||||
throw restoredError;
|
||||
}
|
||||
})();
|
||||
if (!restoredResult) {
|
||||
await cleanupFailedSession(
|
||||
sessionId,
|
||||
runtimeLogger,
|
||||
"send_missing_result_after_restore",
|
||||
);
|
||||
throw new Error("runtime send returned no result after restore");
|
||||
}
|
||||
runtimeLogger.log("RPC runtime turn completed after restore", {
|
||||
sessionId,
|
||||
finishReason: restoredResult.finishReason,
|
||||
iterations: restoredResult.iterations,
|
||||
});
|
||||
return { result: toRpcTurnResult(restoredResult) };
|
||||
} finally {
|
||||
flushCliLoggerAdapters();
|
||||
await cleanupMaterializedFiles(fileMaterialized.tempDir);
|
||||
}
|
||||
},
|
||||
abortSession: async (sessionId) => {
|
||||
const id = sessionId.trim();
|
||||
if (!id) {
|
||||
return { applied: false };
|
||||
}
|
||||
const known = activeSessions.has(id);
|
||||
await sessionManager.abort(
|
||||
id,
|
||||
new Error("RPC runtime abortSession requested"),
|
||||
);
|
||||
createCliLoggerAdapter({
|
||||
runtime: RPC_RUNTIME_NAME,
|
||||
component: RPC_SESSION_COMPONENT,
|
||||
}).core.log("RPC runtime session abort requested", {
|
||||
sessionId: id,
|
||||
known,
|
||||
});
|
||||
return { applied: known };
|
||||
},
|
||||
stopSession: async (sessionId) => {
|
||||
const id = sessionId.trim();
|
||||
if (!id) {
|
||||
return { applied: false };
|
||||
}
|
||||
const known = activeSessions.has(id);
|
||||
await sessionManager.stop(id);
|
||||
createCliLoggerAdapter({
|
||||
runtime: RPC_RUNTIME_NAME,
|
||||
component: RPC_SESSION_COMPONENT,
|
||||
}).core.log("RPC runtime session stopped", {
|
||||
sessionId: id,
|
||||
known,
|
||||
});
|
||||
flushCliLoggerAdapters();
|
||||
activeSessions.delete(id);
|
||||
sessionModes.delete(id);
|
||||
hookService.unregisterSession(id);
|
||||
return { applied: known };
|
||||
},
|
||||
runProviderAction: async (request) => runProviderAction(request),
|
||||
runProviderOAuthLogin: async (provider) => runProviderOAuthLogin(provider),
|
||||
dispose: async () => {
|
||||
unsubscribeEventBridge();
|
||||
await stopTrackedSessions("rpc_runtime_shutdown");
|
||||
await sessionManager.dispose("rpc_runtime_shutdown");
|
||||
await hookService.shutdown();
|
||||
flushCliLoggerAdapters();
|
||||
activeSessions.clear();
|
||||
sessionModes.clear();
|
||||
eventClient.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { subscribeRuntimeEventBridge } from "./event-bridge";
|
||||
|
||||
describe("subscribeRuntimeEventBridge", () => {
|
||||
it("publishes agent error events to the runtime stream", async () => {
|
||||
let listener: ((event: unknown) => void) | undefined;
|
||||
const sessionManager = {
|
||||
subscribe: vi.fn((cb: (event: unknown) => void) => {
|
||||
listener = cb;
|
||||
return () => {
|
||||
listener = undefined;
|
||||
};
|
||||
}),
|
||||
};
|
||||
const publishEvent = vi.fn().mockResolvedValue(undefined);
|
||||
const unsubscribe = subscribeRuntimeEventBridge({
|
||||
sessionManager: sessionManager as any,
|
||||
eventClient: {
|
||||
publishEvent,
|
||||
} as any,
|
||||
});
|
||||
|
||||
listener?.({
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-123",
|
||||
event: {
|
||||
type: "error",
|
||||
error: new Error("provider exploded"),
|
||||
recoverable: true,
|
||||
iteration: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(publishEvent).toHaveBeenCalledWith({
|
||||
sessionId: "session-123",
|
||||
eventType: "runtime.chat.error",
|
||||
payload: {
|
||||
message: "provider exploded",
|
||||
recoverable: true,
|
||||
iteration: 1,
|
||||
},
|
||||
sourceClientId: "cli-rpc-runtime",
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
import type {
|
||||
AgentEvent,
|
||||
LocalRuntimeHost,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
} from "@clinebot/core";
|
||||
import {
|
||||
RPC_TEAM_LIFECYCLE_EVENT_TYPE,
|
||||
RPC_TEAM_PROGRESS_EVENT_TYPE,
|
||||
type TeamProgressProjectionEvent,
|
||||
} from "@clinebot/core";
|
||||
import type { RpcSessionClient } from "@clinebot/rpc";
|
||||
|
||||
export function createRpcToolApprovalRequester(input: {
|
||||
eventClient: RpcSessionClient;
|
||||
runtimeClientId: string;
|
||||
sessionId: string;
|
||||
}): (request: ToolApprovalRequest) => Promise<ToolApprovalResult> {
|
||||
return async (request) => {
|
||||
let inputJson = "";
|
||||
try {
|
||||
inputJson = JSON.stringify(request.input ?? null);
|
||||
} catch {
|
||||
inputJson = "";
|
||||
}
|
||||
const decision = await input.eventClient.requestToolApproval({
|
||||
sessionId: input.sessionId,
|
||||
toolCallId: request.toolCallId,
|
||||
toolName: request.toolName,
|
||||
inputJson,
|
||||
requesterClientId: input.runtimeClientId,
|
||||
});
|
||||
if (!decision.decided) {
|
||||
return {
|
||||
approved: false,
|
||||
reason:
|
||||
decision.reason || `Tool "${request.toolName}" approval timed out`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
approved: decision.approved,
|
||||
reason: decision.reason || undefined,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function publishRuntimeEvent(input: {
|
||||
eventClient: RpcSessionClient;
|
||||
sessionId: string;
|
||||
eventType: string;
|
||||
payload: unknown;
|
||||
}): void {
|
||||
const trimmedSessionId = input.sessionId.trim();
|
||||
if (!trimmedSessionId) {
|
||||
return;
|
||||
}
|
||||
void input.eventClient
|
||||
.publishEvent({
|
||||
sessionId: trimmedSessionId,
|
||||
eventType: input.eventType,
|
||||
payload: (input.payload ?? {}) as Record<string, unknown>,
|
||||
sourceClientId: "cli-rpc-runtime",
|
||||
})
|
||||
.catch(() => {
|
||||
// Best effort: runtime execution should not fail on event publish errors.
|
||||
});
|
||||
}
|
||||
|
||||
function publishFromAgentEvent(input: {
|
||||
eventClient: RpcSessionClient;
|
||||
sessionId: string;
|
||||
event: AgentEvent;
|
||||
}): void {
|
||||
if (input.event.type === "error") {
|
||||
publishRuntimeEvent({
|
||||
eventClient: input.eventClient,
|
||||
sessionId: input.sessionId,
|
||||
eventType: "runtime.chat.error",
|
||||
payload: {
|
||||
message: input.event.error.message,
|
||||
recoverable: input.event.recoverable,
|
||||
iteration: input.event.iteration,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
input.event.type === "content_start" &&
|
||||
input.event.contentType === "text"
|
||||
) {
|
||||
publishRuntimeEvent({
|
||||
eventClient: input.eventClient,
|
||||
sessionId: input.sessionId,
|
||||
eventType: "runtime.chat.text_delta",
|
||||
payload: {
|
||||
text: input.event.text ?? "",
|
||||
accumulated: input.event.accumulated,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
input.event.type === "content_start" &&
|
||||
input.event.contentType === "tool"
|
||||
) {
|
||||
publishRuntimeEvent({
|
||||
eventClient: input.eventClient,
|
||||
sessionId: input.sessionId,
|
||||
eventType: "runtime.chat.tool_call_start",
|
||||
payload: {
|
||||
toolCallId: input.event.toolCallId,
|
||||
toolName: input.event.toolName,
|
||||
input: input.event.input,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
input.event.type === "content_end" &&
|
||||
input.event.contentType === "tool"
|
||||
) {
|
||||
publishRuntimeEvent({
|
||||
eventClient: input.eventClient,
|
||||
sessionId: input.sessionId,
|
||||
eventType: "runtime.chat.tool_call_end",
|
||||
payload: {
|
||||
toolCallId: input.event.toolCallId,
|
||||
toolName: input.event.toolName,
|
||||
output: input.event.output,
|
||||
error: input.event.error,
|
||||
durationMs: input.event.durationMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeRuntimeEventBridge(input: {
|
||||
sessionManager: LocalRuntimeHost;
|
||||
eventClient: RpcSessionClient;
|
||||
}): () => void {
|
||||
return input.sessionManager.subscribe((coreEvent) => {
|
||||
if (coreEvent.type === "agent_event") {
|
||||
publishFromAgentEvent({
|
||||
eventClient: input.eventClient,
|
||||
sessionId: coreEvent.payload.sessionId,
|
||||
event: coreEvent.payload.event,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (coreEvent.type === "pending_prompts") {
|
||||
publishRuntimeEvent({
|
||||
eventClient: input.eventClient,
|
||||
sessionId: coreEvent.payload.sessionId,
|
||||
eventType: "runtime.chat.pending_prompts",
|
||||
payload: {
|
||||
prompts: coreEvent.payload.prompts,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (coreEvent.type !== "team_progress") {
|
||||
return;
|
||||
}
|
||||
const payload: TeamProgressProjectionEvent = {
|
||||
type: "team_progress_projection",
|
||||
version: 1,
|
||||
sessionId: coreEvent.payload.sessionId,
|
||||
summary: coreEvent.payload.summary,
|
||||
lastEvent: coreEvent.payload.lifecycle,
|
||||
};
|
||||
publishRuntimeEvent({
|
||||
eventClient: input.eventClient,
|
||||
sessionId: coreEvent.payload.sessionId,
|
||||
eventType: RPC_TEAM_PROGRESS_EVENT_TYPE,
|
||||
payload,
|
||||
});
|
||||
publishRuntimeEvent({
|
||||
eventClient: input.eventClient,
|
||||
sessionId: coreEvent.payload.sessionId,
|
||||
eventType: RPC_TEAM_LIFECYCLE_EVENT_TYPE,
|
||||
payload: coreEvent.payload.lifecycle,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import type {
|
||||
RpcClineAccountActionRequest,
|
||||
RpcOAuthProviderId,
|
||||
RpcProviderActionRequest,
|
||||
} from "@clinebot/core";
|
||||
import {
|
||||
addLocalProvider,
|
||||
ClineAccountService,
|
||||
ensureCustomProvidersLoaded,
|
||||
executeRpcClineAccountAction,
|
||||
getLocalProviderModels,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
resolveLocalClineAuthToken,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
} from "@clinebot/core";
|
||||
|
||||
export async function runProviderAction(
|
||||
request: RpcProviderActionRequest,
|
||||
): Promise<{ result: unknown }> {
|
||||
const manager = new ProviderSettingsManager();
|
||||
await ensureCustomProvidersLoaded(manager);
|
||||
const parsed = request;
|
||||
|
||||
if (parsed.action === "clineAccount") {
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
const accountService = new ClineAccountService({
|
||||
apiBaseUrl: settings?.baseUrl?.trim() || "https://api.cline.bot",
|
||||
getAuthToken: async () => resolveLocalClineAuthToken(settings),
|
||||
});
|
||||
return {
|
||||
result: await executeRpcClineAccountAction(
|
||||
parsed as RpcClineAccountActionRequest,
|
||||
accountService,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (parsed.action === "listProviders") {
|
||||
return { result: await listLocalProviders(manager) };
|
||||
}
|
||||
if (parsed.action === "getProviderModels") {
|
||||
return {
|
||||
result: await getLocalProviderModels(
|
||||
parsed.providerId,
|
||||
manager.getProviderConfig(parsed.providerId),
|
||||
),
|
||||
};
|
||||
}
|
||||
if (parsed.action === "addProvider") {
|
||||
return { result: await addLocalProvider(manager, parsed) };
|
||||
}
|
||||
if (parsed.action === "saveProviderSettings") {
|
||||
return { result: saveLocalProviderSettings(manager, parsed) };
|
||||
}
|
||||
throw new Error(`unsupported provider action: ${String(parsed)}`);
|
||||
}
|
||||
|
||||
export async function runProviderOAuthLogin(
|
||||
provider: string,
|
||||
): Promise<{ provider: RpcOAuthProviderId; accessToken: string }> {
|
||||
const providerId = normalizeOAuthProvider(provider);
|
||||
const manager = new ProviderSettingsManager();
|
||||
const existing = manager.getProviderSettings(providerId);
|
||||
const credentials = await loginLocalProvider(providerId, existing, (url) => {
|
||||
throw new Error(`RPC OAuth login cannot open browser directly: ${url}`);
|
||||
});
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
const resolvedKey = saved.auth?.accessToken ?? saved.apiKey ?? "";
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: resolvedKey,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RpcSaveProviderSettingsActionRequest } from "@clinebot/core";
|
||||
import type { SaveProviderSettingsActionRequest } from "@clinebot/core";
|
||||
import {
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderSettings,
|
||||
@@ -29,7 +29,7 @@ describe("saveLocalProviderSettings", () => {
|
||||
providerId: "openai",
|
||||
apiKey: null,
|
||||
baseUrl: null,
|
||||
} as unknown as RpcSaveProviderSettingsActionRequest,
|
||||
} as unknown as SaveProviderSettingsActionRequest,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
@@ -66,7 +66,7 @@ describe("saveLocalProviderSettings", () => {
|
||||
providerId: "openai",
|
||||
apiKey: " ",
|
||||
baseUrl: "",
|
||||
} as RpcSaveProviderSettingsActionRequest,
|
||||
} as SaveProviderSettingsActionRequest,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
@@ -104,7 +104,7 @@ describe("saveLocalProviderSettings", () => {
|
||||
action: "saveProviderSettings",
|
||||
providerId: "cline",
|
||||
apiKey: "manual-new",
|
||||
} as RpcSaveProviderSettingsActionRequest,
|
||||
} as SaveProviderSettingsActionRequest,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@clinebot/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@clinebot/core")>("@clinebot/core");
|
||||
return {
|
||||
...actual,
|
||||
Llms: {
|
||||
...actual.Llms,
|
||||
normalizeProviderId: vi.fn((provider: string) => provider),
|
||||
},
|
||||
setHomeDir: vi.fn(),
|
||||
setHomeDirIfUnset: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../runtime/prompt", () => ({
|
||||
resolveSystemPrompt: vi.fn(async () => "resolved system prompt"),
|
||||
}));
|
||||
|
||||
vi.mock("../../logging/adapter", () => ({
|
||||
createCliLoggerAdapter: vi.fn(() => ({
|
||||
core: {
|
||||
debug: vi.fn(),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/telemetry", () => ({
|
||||
getCliTelemetryService: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
describe("buildSessionStartInput", () => {
|
||||
it("keeps maxIterations unset when not provided", async () => {
|
||||
const { buildSessionStartInput } = await import("./session-helpers");
|
||||
const hooks = { onRunStart: vi.fn() };
|
||||
const built = await buildSessionStartInput({
|
||||
sessionId: "session-123",
|
||||
hooks,
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
workspaceRoot: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
enableSpawn: true,
|
||||
enableTeams: true,
|
||||
autoApproveTools: true,
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(built.sessionInput.config.sessionId).toBe("session-123");
|
||||
expect(built.sessionInput.config.maxIterations).toBeUndefined();
|
||||
expect(built.sessionInput.config.compaction).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(built.sessionInput.config.hooks).toBe(hooks);
|
||||
});
|
||||
|
||||
it("uses explicit yolo mode for prompt resolution and internal yolo config", async () => {
|
||||
const { buildSessionStartInput } = await import("./session-helpers");
|
||||
const { resolveSystemPrompt } = await import("../../runtime/prompt");
|
||||
|
||||
const built = await buildSessionStartInput({
|
||||
sessionId: "session-yolo",
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
workspaceRoot: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
mode: "yolo",
|
||||
enableTools: true,
|
||||
enableSpawn: true,
|
||||
enableTeams: true,
|
||||
autoApproveTools: true,
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(resolveSystemPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: "yolo",
|
||||
}),
|
||||
);
|
||||
expect(built.mode).toBe("yolo");
|
||||
expect(built.sessionInput.config.mode).toBe("yolo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rpc-runtime payload parsing", () => {
|
||||
it("normalizes null maxIterations in start payload to undefined", async () => {
|
||||
const { parseStartPayload } = await import("./session-helpers");
|
||||
const parsed = parseStartPayload({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
workspaceRoot: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
enableSpawn: false,
|
||||
enableTeams: false,
|
||||
autoApproveTools: true,
|
||||
maxIterations: null,
|
||||
} as any);
|
||||
expect(parsed.maxIterations).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes null maxIterations in send payload config to undefined", async () => {
|
||||
const { parseSendPayload } = await import("./session-helpers");
|
||||
const parsed = parseSendPayload({
|
||||
prompt: "hey",
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
workspaceRoot: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
enableSpawn: false,
|
||||
enableTeams: false,
|
||||
autoApproveTools: true,
|
||||
maxIterations: null,
|
||||
},
|
||||
} as any);
|
||||
expect(parsed.config.maxIterations).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,251 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import {
|
||||
type AgentHooks,
|
||||
type ClineCore,
|
||||
Llms,
|
||||
type RpcChatMessage,
|
||||
type RpcChatRunTurnRequest,
|
||||
type RpcChatRuntimeConfigBase,
|
||||
type RpcChatStartSessionRequest,
|
||||
type RpcChatTurnResult,
|
||||
SessionSource,
|
||||
setHomeDir,
|
||||
setHomeDirIfUnset,
|
||||
} from "@clinebot/core";
|
||||
import { createCliLoggerAdapter } from "../../logging/adapter";
|
||||
import { resolveSystemPrompt } from "../../runtime/prompt";
|
||||
import { resolveCliSessionMetadata } from "../../utils/enterprise";
|
||||
import { getCliTelemetryService } from "../../utils/telemetry";
|
||||
|
||||
function sanitizeFilename(name: string, index: number): string {
|
||||
const base = basename(name || `attachment-${index + 1}`);
|
||||
return base.replace(/[^\w.-]+/g, "_");
|
||||
}
|
||||
|
||||
export async function materializeUserFiles(
|
||||
files: Array<{ name: string; content: string }> | undefined,
|
||||
): Promise<{ tempDir?: string; paths: string[] }> {
|
||||
if (!files || files.length === 0) {
|
||||
return { paths: [] };
|
||||
}
|
||||
|
||||
const resolvedTempDir = await mkdtemp(`${tmpdir()}/cline-rpc-attachments-`);
|
||||
const paths: string[] = [];
|
||||
for (const [index, file] of files.entries()) {
|
||||
const safeName = sanitizeFilename(file.name, index);
|
||||
const path = join(resolvedTempDir, safeName);
|
||||
await writeFile(path, file.content, "utf8");
|
||||
paths.push(path);
|
||||
}
|
||||
return { tempDir: resolvedTempDir, paths };
|
||||
}
|
||||
|
||||
export async function cleanupMaterializedFiles(
|
||||
tempDir?: string,
|
||||
): Promise<void> {
|
||||
if (!tempDir) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await rm(tempDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} catch {
|
||||
// best effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMode(
|
||||
config: RpcChatStartSessionRequest,
|
||||
): "act" | "plan" | "yolo" {
|
||||
return config.mode === "plan"
|
||||
? "plan"
|
||||
: config.mode === "yolo"
|
||||
? "yolo"
|
||||
: "act";
|
||||
}
|
||||
|
||||
function resolveSessionCwd(config: RpcChatStartSessionRequest): string {
|
||||
return (config.cwd?.trim() || config.workspaceRoot).trim();
|
||||
}
|
||||
|
||||
function resolveToolPolicies(
|
||||
config: RpcChatStartSessionRequest,
|
||||
): RpcChatRuntimeConfigBase["toolPolicies"] {
|
||||
const explicit = config.toolPolicies;
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
return {
|
||||
"*": {
|
||||
autoApprove: config.autoApproveTools !== false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildSessionStartInput(input: {
|
||||
config: RpcChatStartSessionRequest;
|
||||
sessionId?: string;
|
||||
initialMessages?: Llms.Message[];
|
||||
hooks?: AgentHooks;
|
||||
}): Promise<{
|
||||
mode: "act" | "plan" | "yolo";
|
||||
sessionInput: Parameters<ClineCore["start"]>[0];
|
||||
}> {
|
||||
const { config } = input;
|
||||
const mode = resolveMode(config);
|
||||
const cwd = resolveSessionCwd(config);
|
||||
const providerId = Llms.normalizeProviderId(config.provider);
|
||||
const systemPrompt = await resolveSystemPrompt({
|
||||
cwd,
|
||||
explicitSystemPrompt: config.systemPrompt,
|
||||
providerId,
|
||||
rules: config.rules,
|
||||
mode,
|
||||
});
|
||||
const logger = createCliLoggerAdapter({
|
||||
runtime: "rpc-runtime",
|
||||
component: "session-runtime",
|
||||
runtimeConfig: config.logger,
|
||||
});
|
||||
const sessionMetadata = await resolveCliSessionMetadata(input.sessionId);
|
||||
|
||||
return {
|
||||
mode,
|
||||
sessionInput: {
|
||||
source: config.source || SessionSource.CLI,
|
||||
interactive: config.interactive !== false,
|
||||
initialMessages: input.initialMessages,
|
||||
...(sessionMetadata ? { sessionMetadata } : {}),
|
||||
config: {
|
||||
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
||||
providerId,
|
||||
modelId: config.model,
|
||||
mode,
|
||||
apiKey: config.apiKey?.trim() || undefined,
|
||||
cwd,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
systemPrompt,
|
||||
maxIterations: config.maxIterations,
|
||||
compaction: {
|
||||
enabled: true,
|
||||
},
|
||||
checkpoint: {
|
||||
enabled: true,
|
||||
},
|
||||
enableTools: config.enableTools,
|
||||
enableSpawnAgent: config.enableSpawn,
|
||||
enableAgentTeams: config.enableTeams,
|
||||
disableMcpSettingsTools: config.disableMcpSettingsTools,
|
||||
teamName: config.teamName,
|
||||
missionLogIntervalSteps: config.missionStepInterval,
|
||||
missionLogIntervalMs: config.missionTimeIntervalMs,
|
||||
hooks: input.hooks,
|
||||
logger: logger.core,
|
||||
telemetry: getCliTelemetryService(logger.core),
|
||||
},
|
||||
toolPolicies: resolveToolPolicies(config),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyHomeDir(config: RpcChatStartSessionRequest): void {
|
||||
const homeDir = config.sessions?.homeDir?.trim();
|
||||
if (homeDir) {
|
||||
setHomeDir(homeDir);
|
||||
return;
|
||||
}
|
||||
setHomeDirIfUnset(homedir());
|
||||
}
|
||||
|
||||
export function parseStartPayload(
|
||||
request: RpcChatStartSessionRequest,
|
||||
): RpcChatStartSessionRequest {
|
||||
const parsed = request as RpcChatStartSessionRequest & {
|
||||
maxIterations?: unknown;
|
||||
};
|
||||
const normalizedMaxIterations =
|
||||
typeof parsed.maxIterations === "number" &&
|
||||
Number.isFinite(parsed.maxIterations) &&
|
||||
parsed.maxIterations > 0
|
||||
? Math.floor(parsed.maxIterations)
|
||||
: undefined;
|
||||
return {
|
||||
...parsed,
|
||||
maxIterations: normalizedMaxIterations,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSendPayload(
|
||||
request: RpcChatRunTurnRequest,
|
||||
): RpcChatRunTurnRequest {
|
||||
const parsed = request as RpcChatRunTurnRequest & {
|
||||
config?: RpcChatRunTurnRequest["config"] & {
|
||||
maxIterations?: unknown;
|
||||
};
|
||||
};
|
||||
if (!parsed.config) {
|
||||
return parsed;
|
||||
}
|
||||
const normalizedMaxIterations =
|
||||
typeof parsed.config.maxIterations === "number" &&
|
||||
Number.isFinite(parsed.config.maxIterations) &&
|
||||
parsed.config.maxIterations > 0
|
||||
? Math.floor(parsed.config.maxIterations)
|
||||
: undefined;
|
||||
return {
|
||||
...parsed,
|
||||
config: {
|
||||
...parsed.config,
|
||||
maxIterations: normalizedMaxIterations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toRpcMessages(messages: Llms.Message[]): RpcChatMessage[] {
|
||||
return messages as unknown as RpcChatMessage[];
|
||||
}
|
||||
|
||||
export function toRpcTurnResult(result: {
|
||||
text: string;
|
||||
usage: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCost?: number;
|
||||
};
|
||||
iterations: number;
|
||||
finishReason: string;
|
||||
messages: Llms.Message[];
|
||||
toolCalls: Array<{
|
||||
name: string;
|
||||
input: unknown;
|
||||
output: unknown;
|
||||
error?: string;
|
||||
durationMs?: number;
|
||||
}>;
|
||||
}): RpcChatTurnResult {
|
||||
return {
|
||||
text: result.text,
|
||||
usage: result.usage,
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
iterations: result.iterations,
|
||||
finishReason: result.finishReason,
|
||||
messages: toRpcMessages(result.messages),
|
||||
toolCalls: result.toolCalls.map((call) => ({
|
||||
name: call.name,
|
||||
input: call.input,
|
||||
output: call.output,
|
||||
error: call.error,
|
||||
durationMs: call.durationMs,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRestoreSession(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error ?? "");
|
||||
return message.includes("session not found");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RpcProviderCapability } from "@clinebot/core";
|
||||
import type { ProviderCapability } from "@clinebot/core";
|
||||
|
||||
export type StoredModelsFile = {
|
||||
version: 1;
|
||||
@@ -9,7 +9,7 @@ export type StoredModelsFile = {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
defaultModelId: string;
|
||||
capabilities?: RpcProviderCapability[];
|
||||
capabilities?: ProviderCapability[];
|
||||
modelsSourceUrl?: string;
|
||||
};
|
||||
models: Record<
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockSpawn,
|
||||
mockGetRpcServerHealth,
|
||||
mockStopRuntimeSession,
|
||||
mockClientClose,
|
||||
mockCreateServer,
|
||||
mockRequestRpcServerShutdown,
|
||||
rpcProtocolVersion,
|
||||
rpcBuildVersion,
|
||||
} = vi.hoisted(() => {
|
||||
return {
|
||||
mockSpawn: vi.fn(),
|
||||
mockGetRpcServerHealth: vi.fn(),
|
||||
mockStopRuntimeSession: vi.fn(),
|
||||
mockClientClose: vi.fn(),
|
||||
mockCreateServer: vi.fn(),
|
||||
mockRequestRpcServerShutdown: vi.fn(),
|
||||
rpcProtocolVersion: "1",
|
||||
rpcBuildVersion: "rpc-build-test",
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: mockSpawn,
|
||||
spawnSync: vi.fn(),
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:net", () => ({
|
||||
createServer: mockCreateServer,
|
||||
}));
|
||||
|
||||
vi.mock("@clinebot/rpc", () => ({
|
||||
getRpcServerDefaultAddress: vi.fn(() => "127.0.0.1:4317"),
|
||||
getRpcServerHealth: mockGetRpcServerHealth,
|
||||
registerRpcClient: vi.fn(),
|
||||
requestRpcServerShutdown: mockRequestRpcServerShutdown,
|
||||
startRpcServer: vi.fn(),
|
||||
stopRpcServer: vi.fn(),
|
||||
RPC_BUILD_VERSION: rpcBuildVersion,
|
||||
RPC_PROTOCOL_VERSION: rpcProtocolVersion,
|
||||
RpcSessionClient: class {
|
||||
async stopRuntimeSession(sessionId: string) {
|
||||
return mockStopRuntimeSession(sessionId);
|
||||
}
|
||||
|
||||
close() {
|
||||
mockClientClose();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { runRpcEnsureCommand } from "./rpc";
|
||||
|
||||
describe("runRpcEnsureCommand", () => {
|
||||
const tempDirs: string[] = [];
|
||||
const originalArgv = [...process.argv];
|
||||
const savedEnvKeys = [
|
||||
"CLINE_DATA_DIR",
|
||||
"CLINE_RPC_OWNER_ID",
|
||||
"CLINE_RPC_BUILD_ID",
|
||||
"CLINE_RPC_DISCOVERY_PATH",
|
||||
"CLINE_RPC_STARTUP_LOCK_HELD",
|
||||
] as const;
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of savedEnvKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of savedEnvKeys) {
|
||||
if (savedEnv[key] !== undefined) {
|
||||
process.env[key] = savedEnv[key];
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
process.argv = [...originalArgv];
|
||||
vi.clearAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates the rpc lock parent directory when it does not exist", async () => {
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cline-rpc-lock-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
process.argv[1] = path.join(dataDir, "clite.js");
|
||||
const address = "127.0.0.1:65432";
|
||||
|
||||
mockSpawn.mockReturnValue({ pid: 1234, unref: vi.fn() });
|
||||
mockCreateServer.mockImplementation(() => {
|
||||
let onListening: (() => void) | undefined;
|
||||
return {
|
||||
once: (event: string, handler: () => void) => {
|
||||
if (event === "listening") {
|
||||
onListening = handler;
|
||||
}
|
||||
},
|
||||
listen: () => {
|
||||
onListening?.();
|
||||
},
|
||||
close: (handler?: () => void) => {
|
||||
handler?.();
|
||||
},
|
||||
};
|
||||
});
|
||||
mockGetRpcServerHealth.mockResolvedValueOnce(undefined).mockResolvedValue({
|
||||
running: true,
|
||||
serverId: "new-server",
|
||||
address,
|
||||
startedAt: new Date().toISOString(),
|
||||
rpcVersion: rpcProtocolVersion,
|
||||
});
|
||||
mockStopRuntimeSession.mockRejectedValue(new Error("probe failed"));
|
||||
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const code = await runRpcEnsureCommand(
|
||||
{ address, json: true },
|
||||
(text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
(text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(code).toBe(0);
|
||||
expect(output).toHaveLength(1);
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
running: true,
|
||||
requestedAddress: address,
|
||||
address,
|
||||
action: "started",
|
||||
});
|
||||
expect(existsSync(path.join(dataDir, "locks"))).toBe(true);
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockClientClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reuses the server when rpc version matches", async () => {
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cline-rpc-ver-match-"));
|
||||
tempDirs.push(dataDir);
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
process.argv[1] = path.join(dataDir, "clite.js");
|
||||
const address = "127.0.0.1:65432";
|
||||
|
||||
// Server is healthy and reports matching version.
|
||||
mockGetRpcServerHealth.mockResolvedValue({
|
||||
running: true,
|
||||
serverId: "test-server",
|
||||
address,
|
||||
startedAt: new Date().toISOString(),
|
||||
rpcVersion: rpcProtocolVersion,
|
||||
});
|
||||
// Runtime method probe succeeds (not UNIMPLEMENTED).
|
||||
mockStopRuntimeSession.mockRejectedValue(new Error("session not found"));
|
||||
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const code = await runRpcEnsureCommand(
|
||||
{ address, json: true },
|
||||
(text) => output.push(text ?? ""),
|
||||
(text) => errors.push(text),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(code).toBe(0);
|
||||
const result = JSON.parse(output[0] || "");
|
||||
expect(result).toMatchObject({
|
||||
running: true,
|
||||
address,
|
||||
action: "reuse",
|
||||
});
|
||||
// Should NOT spawn a new server.
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces the current owner's stale sidecar when the protocol mismatches", async () => {
|
||||
const dataDir = mkdtempSync(
|
||||
path.join(os.tmpdir(), "cline-rpc-ver-mismatch-"),
|
||||
);
|
||||
tempDirs.push(dataDir);
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
process.argv[1] = path.join(dataDir, "clite.js");
|
||||
process.env.CLINE_RPC_OWNER_ID = "owner-test";
|
||||
process.env.CLINE_RPC_BUILD_ID = "build-new";
|
||||
process.env.CLINE_RPC_DISCOVERY_PATH = path.join(dataDir, "rpc-owner.json");
|
||||
const address = "127.0.0.1:65432";
|
||||
writeFileSync(
|
||||
process.env.CLINE_RPC_DISCOVERY_PATH,
|
||||
JSON.stringify({
|
||||
ownerId: "owner-test",
|
||||
buildId: "build-old",
|
||||
address,
|
||||
protocolVersion: rpcProtocolVersion,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
mockCreateServer.mockImplementation(() => {
|
||||
let onListening: (() => void) | undefined;
|
||||
return {
|
||||
once: (event: string, handler: () => void) => {
|
||||
if (event === "listening") {
|
||||
onListening = handler;
|
||||
}
|
||||
},
|
||||
listen: () => {
|
||||
onListening?.();
|
||||
},
|
||||
close: (handler?: () => void) => {
|
||||
handler?.();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
mockGetRpcServerHealth
|
||||
.mockResolvedValueOnce({
|
||||
running: true,
|
||||
serverId: "old-server",
|
||||
address,
|
||||
startedAt: new Date().toISOString(),
|
||||
rpcVersion: "old-version",
|
||||
})
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValue({
|
||||
running: true,
|
||||
serverId: "new-server",
|
||||
address,
|
||||
startedAt: new Date().toISOString(),
|
||||
rpcVersion: rpcProtocolVersion,
|
||||
});
|
||||
|
||||
// Runtime method probe succeeds (not UNIMPLEMENTED).
|
||||
mockStopRuntimeSession.mockRejectedValue(new Error("session not found"));
|
||||
// Shutdown accepted.
|
||||
mockRequestRpcServerShutdown.mockResolvedValue({ accepted: true });
|
||||
// New detached server spawned.
|
||||
mockSpawn.mockReturnValue({ pid: 5678, unref: vi.fn() });
|
||||
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const code = await runRpcEnsureCommand(
|
||||
{ address, json: true },
|
||||
(text) => output.push(text ?? ""),
|
||||
(text) => errors.push(text),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(code).toBe(0);
|
||||
const result = JSON.parse(output[0] || "");
|
||||
expect(result).toMatchObject({
|
||||
running: true,
|
||||
address,
|
||||
action: "started",
|
||||
});
|
||||
expect(mockRequestRpcServerShutdown).toHaveBeenCalledWith(address);
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts a new sidecar on a different port for a foreign old server", async () => {
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cline-rpc-ver-empty-"));
|
||||
tempDirs.push(dataDir);
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
process.argv[1] = path.join(dataDir, "clite.js");
|
||||
const address = "127.0.0.1:65432";
|
||||
|
||||
mockCreateServer.mockImplementation(() => {
|
||||
let onListening: (() => void) | undefined;
|
||||
return {
|
||||
once: (event: string, handler: () => void) => {
|
||||
if (event === "listening") {
|
||||
onListening = handler;
|
||||
}
|
||||
},
|
||||
listen: () => {
|
||||
onListening?.();
|
||||
},
|
||||
close: (handler?: () => void) => {
|
||||
handler?.();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Server healthy but no rpcVersion field (pre-upgrade server).
|
||||
mockGetRpcServerHealth
|
||||
.mockResolvedValueOnce({
|
||||
running: true,
|
||||
serverId: "old-server",
|
||||
address,
|
||||
startedAt: new Date().toISOString(),
|
||||
// No rpcVersion field.
|
||||
})
|
||||
.mockResolvedValue({
|
||||
running: true,
|
||||
serverId: "new-server",
|
||||
address: "127.0.0.1:65433",
|
||||
startedAt: new Date().toISOString(),
|
||||
rpcVersion: rpcProtocolVersion,
|
||||
});
|
||||
|
||||
mockStopRuntimeSession.mockRejectedValue(new Error("session not found"));
|
||||
mockSpawn.mockReturnValue({ pid: 9999, unref: vi.fn() });
|
||||
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const code = await runRpcEnsureCommand(
|
||||
{ address, json: true },
|
||||
(text) => output.push(text ?? ""),
|
||||
(text) => errors.push(text),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(code).toBe(0);
|
||||
const result = JSON.parse(output[0] || "");
|
||||
expect(result).toMatchObject({
|
||||
running: true,
|
||||
address: "127.0.0.1:65433",
|
||||
action: "new-port",
|
||||
});
|
||||
expect(mockRequestRpcServerShutdown).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replaces an auth-gated listener instead of reusing it", async () => {
|
||||
const dataDir = mkdtempSync(
|
||||
path.join(os.tmpdir(), "cline-rpc-auth-gated-"),
|
||||
);
|
||||
tempDirs.push(dataDir);
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
process.argv[1] = path.join(dataDir, "clite.js");
|
||||
const address = "127.0.0.1:65432";
|
||||
|
||||
mockCreateServer.mockImplementation(() => {
|
||||
let onListening: (() => void) | undefined;
|
||||
return {
|
||||
once: (event: string, handler: () => void) => {
|
||||
if (event === "listening") {
|
||||
onListening = handler;
|
||||
}
|
||||
},
|
||||
listen: () => {
|
||||
onListening?.();
|
||||
},
|
||||
close: (handler?: () => void) => {
|
||||
handler?.();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
mockGetRpcServerHealth
|
||||
.mockResolvedValueOnce({
|
||||
running: true,
|
||||
serverId: "foreign-auth-server",
|
||||
address,
|
||||
startedAt: new Date().toISOString(),
|
||||
rpcVersion: rpcProtocolVersion,
|
||||
})
|
||||
.mockResolvedValue({
|
||||
running: true,
|
||||
serverId: "new-server",
|
||||
address: "127.0.0.1:65433",
|
||||
startedAt: new Date().toISOString(),
|
||||
rpcVersion: rpcProtocolVersion,
|
||||
});
|
||||
|
||||
mockStopRuntimeSession
|
||||
.mockRejectedValueOnce(
|
||||
new Error(
|
||||
"3 INVALID_ARGUMENT: Error: 401 Missing Authentication header",
|
||||
),
|
||||
)
|
||||
.mockRejectedValue(new Error("session not found"));
|
||||
mockSpawn.mockReturnValue({ pid: 12345, unref: vi.fn() });
|
||||
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const code = await runRpcEnsureCommand(
|
||||
{ address, json: true },
|
||||
(text) => output.push(text ?? ""),
|
||||
(text) => errors.push(text),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
running: true,
|
||||
address: "127.0.0.1:65433",
|
||||
action: "new-port",
|
||||
});
|
||||
expect(mockRequestRpcServerShutdown).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,509 +0,0 @@
|
||||
import {
|
||||
clearRpcDiscoveryIfAddressMatches,
|
||||
createSqliteRpcSessionBackend,
|
||||
type ResolveRpcRuntimeResult,
|
||||
type RpcOwnerContext,
|
||||
recordRpcDiscovery,
|
||||
resolveEnsuredRpcRuntime,
|
||||
withRpcStartupLock,
|
||||
} from "@clinebot/core";
|
||||
import {
|
||||
getRpcServerHealth,
|
||||
RPC_PROTOCOL_VERSION,
|
||||
registerRpcClient,
|
||||
requestRpcServerShutdown,
|
||||
startRpcServer,
|
||||
stopRpcServer,
|
||||
} from "@clinebot/rpc";
|
||||
import { CLINE_DEFAULT_RPC_ADDRESS } from "@clinebot/shared";
|
||||
import { Command } from "commander";
|
||||
import { createCliLoggerAdapter } from "../logging/adapter";
|
||||
import {
|
||||
ensureCliRpcRuntime,
|
||||
ensureCliRpcRuntimeAddress,
|
||||
resolveCurrentCliRpcOwnerContext,
|
||||
} from "../utils/rpc-runtime";
|
||||
import { createRpcRuntimeHandlers } from "./rpc-runtime";
|
||||
|
||||
const c = {
|
||||
dim: "\x1b[2m",
|
||||
reset: "\x1b[0m",
|
||||
};
|
||||
|
||||
interface RpcCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiny helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
function collectMeta(value: string, previous: string[]): string[] {
|
||||
return previous.concat(value);
|
||||
}
|
||||
|
||||
function parseMetaEntries(entries: string[]): Record<string, string> {
|
||||
const metadata: Record<string, string> = {};
|
||||
for (const raw of entries) {
|
||||
const sep = raw.indexOf("=");
|
||||
if (sep <= 0 || sep >= raw.length - 1) continue;
|
||||
const key = raw.slice(0, sep).trim();
|
||||
const value = raw.slice(sep + 1).trim();
|
||||
if (key && value) metadata[key] = value;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function resolveCurrentRpcOwnerContext(): RpcOwnerContext {
|
||||
return resolveCurrentCliRpcOwnerContext();
|
||||
}
|
||||
|
||||
export async function ensureRpcRuntimeAddress(
|
||||
requestedAddress: string,
|
||||
): Promise<string> {
|
||||
return ensureCliRpcRuntimeAddress(requestedAddress);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatUptime(startedAt: string): string {
|
||||
const startMs = new Date(startedAt).getTime();
|
||||
if (!Number.isFinite(startMs)) return "unknown";
|
||||
let seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
||||
const days = Math.floor(seconds / 86400);
|
||||
seconds %= 86400;
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
seconds %= 3600;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
seconds %= 60;
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
parts.push(`${seconds}s`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runRpcEnsureCommand(
|
||||
options: { address: string; json?: boolean },
|
||||
writeln: (text?: string) => void,
|
||||
writeErr: (text: string) => void,
|
||||
): Promise<number> {
|
||||
const { address: requestedAddress, json: jsonOutput } = options;
|
||||
let ensured: ResolveRpcRuntimeResult | undefined;
|
||||
try {
|
||||
ensured = await ensureCliRpcRuntime(requestedAddress);
|
||||
} catch (error) {
|
||||
writeErr(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
if (!ensured) {
|
||||
writeErr(`failed to ensure rpc runtime at ${requestedAddress}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (jsonOutput) {
|
||||
writeln(
|
||||
JSON.stringify({
|
||||
running: true,
|
||||
requestedAddress,
|
||||
address: ensured.address,
|
||||
action: ensured.action,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
writeln(
|
||||
`${c.dim}[rpc] ensured address=${ensured.address} (requested=${requestedAddress}, action=${ensured.action})${c.reset}`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function runRpcStartCommand(
|
||||
options: { address: string },
|
||||
writeln: (text?: string) => void,
|
||||
writeErr: (text: string) => void,
|
||||
): Promise<number> {
|
||||
const rpcLogger = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
component: "rpc",
|
||||
}).core;
|
||||
const normalizedAddress = options.address;
|
||||
if (!normalizedAddress) {
|
||||
writeErr("rpc start requires a non-empty address");
|
||||
rpcLogger.error?.("RPC start rejected: empty address");
|
||||
return 1;
|
||||
}
|
||||
|
||||
let startAddress = normalizedAddress;
|
||||
let handle: Awaited<ReturnType<typeof startRpcServer>> | undefined;
|
||||
let reusedExisting = false;
|
||||
let existingServerId: string | undefined;
|
||||
const owner = resolveCurrentRpcOwnerContext();
|
||||
let startedAction: "new-port" | "started" = "started";
|
||||
|
||||
await withRpcStartupLock(normalizedAddress, async (lock) => {
|
||||
const ensured = await resolveEnsuredRpcRuntime(normalizedAddress, {
|
||||
owner,
|
||||
lockAlreadyHeld: true,
|
||||
});
|
||||
startAddress = ensured.address;
|
||||
if (ensured.action === "reuse") {
|
||||
reusedExisting = true;
|
||||
existingServerId = (await getRpcServerHealth(startAddress))?.serverId;
|
||||
return;
|
||||
}
|
||||
|
||||
startedAction = ensured.action;
|
||||
process.env.CLINE_RPC_ADDRESS = startAddress;
|
||||
handle = await startRpcServer({
|
||||
address: startAddress,
|
||||
sessionBackend: createSqliteRpcSessionBackend(),
|
||||
runtimeHandlers: createRpcRuntimeHandlers(),
|
||||
scheduler: {
|
||||
logger: rpcLogger,
|
||||
},
|
||||
});
|
||||
await lock.markRunning({
|
||||
resolvedAddress: handle.address,
|
||||
serverId: handle.serverId,
|
||||
});
|
||||
await recordRpcDiscovery(owner, {
|
||||
address: startAddress,
|
||||
pid: process.pid,
|
||||
serverId: handle.serverId,
|
||||
startedAt: handle.startedAt,
|
||||
protocolVersion: RPC_PROTOCOL_VERSION,
|
||||
entryPath: owner.entryPath,
|
||||
});
|
||||
});
|
||||
|
||||
if (reusedExisting) {
|
||||
const health = await getRpcServerHealth(startAddress);
|
||||
await recordRpcDiscovery(owner, {
|
||||
address: startAddress,
|
||||
pid: undefined,
|
||||
serverId: health?.serverId,
|
||||
startedAt: health?.startedAt,
|
||||
protocolVersion: RPC_PROTOCOL_VERSION,
|
||||
entryPath: owner.entryPath,
|
||||
});
|
||||
rpcLogger.log("RPC server activation reused existing instance", {
|
||||
address: startAddress,
|
||||
serverId: existingServerId,
|
||||
action: "reuse",
|
||||
});
|
||||
writeln(
|
||||
`${c.dim}[rpc] already running server_id=${existingServerId ?? "unknown"} address=${startAddress}${c.reset}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!handle) {
|
||||
writeErr(`failed to start rpc server at ${startAddress}`);
|
||||
return 1;
|
||||
}
|
||||
rpcLogger.log("RPC server activation started", {
|
||||
address: handle.address,
|
||||
serverId: handle.serverId,
|
||||
requestedAddress: normalizedAddress,
|
||||
action: startedAction,
|
||||
});
|
||||
writeln(
|
||||
`${c.dim}[rpc] started server_id=${handle.serverId} address=${handle.address}${c.reset}`,
|
||||
);
|
||||
writeln(`${c.dim}[rpc] press Ctrl+C to stop${c.reset}`);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const shutdown = () => {
|
||||
process.off("SIGINT", shutdown);
|
||||
process.off("SIGTERM", shutdown);
|
||||
resolve();
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
});
|
||||
|
||||
await stopRpcServer();
|
||||
await clearRpcDiscoveryIfAddressMatches(owner, handle.address);
|
||||
rpcLogger.log("RPC server stopped", {
|
||||
address: handle.address,
|
||||
serverId: handle.serverId,
|
||||
});
|
||||
writeln(`${c.dim}[rpc] stopped${c.reset}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function runRpcStatusCommand(
|
||||
options: { address: string; json?: boolean },
|
||||
writeln: (text?: string) => void,
|
||||
writeErr: (text: string) => void,
|
||||
): Promise<number> {
|
||||
const normalizedAddress = options.address;
|
||||
if (!normalizedAddress) {
|
||||
writeErr("rpc status requires a non-empty address");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const health = await getRpcServerHealth(normalizedAddress);
|
||||
if (!health?.running) {
|
||||
if (options.json) {
|
||||
writeln(JSON.stringify({ running: false, address: normalizedAddress }));
|
||||
} else {
|
||||
writeln(
|
||||
`${c.dim}[rpc] not running address=${normalizedAddress}${c.reset}`,
|
||||
);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
const uptime = health.startedAt ? formatUptime(health.startedAt) : "unknown";
|
||||
if (options.json) {
|
||||
writeln(
|
||||
JSON.stringify({
|
||||
running: true,
|
||||
serverId: health.serverId,
|
||||
address: health.address,
|
||||
startedAt: health.startedAt || null,
|
||||
uptime,
|
||||
rpcVersion: health.rpcVersion || null,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const version = health.rpcVersion ? ` version=${health.rpcVersion}` : "";
|
||||
writeln(
|
||||
`${c.dim}[rpc] running server_id=${health.serverId} address=${health.address}${version} uptime=${uptime}${c.reset}`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function runRpcStopCommand(
|
||||
options: { address: string },
|
||||
writeln: (text?: string) => void,
|
||||
writeErr: (text: string) => void,
|
||||
): Promise<number> {
|
||||
const normalizedAddress = options.address;
|
||||
const owner = resolveCurrentRpcOwnerContext();
|
||||
if (!normalizedAddress) {
|
||||
writeErr("rpc stop requires a non-empty address");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const health = await getRpcServerHealth(normalizedAddress);
|
||||
if (!health?.running) {
|
||||
writeln(`${c.dim}[rpc] not running address=${normalizedAddress}${c.reset}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const shutdown = await requestRpcServerShutdown(normalizedAddress);
|
||||
if (!shutdown?.accepted) {
|
||||
writeErr(
|
||||
`failed to request rpc shutdown at ${normalizedAddress} (server may have exited)`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (!(await getRpcServerHealth(normalizedAddress))?.running) {
|
||||
await clearRpcDiscoveryIfAddressMatches(owner, normalizedAddress);
|
||||
writeln(
|
||||
`${c.dim}[rpc] stopped server_id=${health.serverId} address=${health.address}${c.reset}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
writeErr(
|
||||
`rpc shutdown requested but server still reports healthy at ${health.address}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function runRpcRegisterCommand(
|
||||
options: {
|
||||
address: string;
|
||||
clientType: string;
|
||||
clientId?: string;
|
||||
meta: string[];
|
||||
},
|
||||
writeln: (text?: string) => void,
|
||||
writeErr: (text: string) => void,
|
||||
): Promise<number> {
|
||||
const registerLogger = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
component: "rpc-register",
|
||||
}).core;
|
||||
const normalizedAddress = options.address;
|
||||
if (!normalizedAddress) {
|
||||
writeErr("rpc register requires a non-empty address");
|
||||
registerLogger.error?.("RPC client registration rejected: empty address");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const metadata = parseMetaEntries(options.meta);
|
||||
const registration = await registerRpcClient(normalizedAddress, {
|
||||
clientId: options.clientId,
|
||||
clientType: options.clientType,
|
||||
metadata,
|
||||
});
|
||||
if (!registration?.registered) {
|
||||
registerLogger.error?.("RPC client registration failed", {
|
||||
address: normalizedAddress,
|
||||
clientType: options.clientType,
|
||||
requestedClientId: options.clientId ?? "",
|
||||
metadata,
|
||||
});
|
||||
writeErr(
|
||||
`failed to register client with rpc server at ${normalizedAddress}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
registerLogger.log("RPC client registered", {
|
||||
address: normalizedAddress,
|
||||
clientType: options.clientType,
|
||||
clientId: registration.clientId,
|
||||
requestedClientId: options.clientId ?? "",
|
||||
metadata,
|
||||
});
|
||||
|
||||
writeln(
|
||||
`${c.dim}[rpc] registered client_id=${registration.clientId} address=${normalizedAddress}${c.reset}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commander command tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_RPC_ADDRESS =
|
||||
process.env.CLINE_RPC_ADDRESS || CLINE_DEFAULT_RPC_ADDRESS;
|
||||
|
||||
export function createRpcCommand(
|
||||
io: RpcCommandIo,
|
||||
setExitCode: (code: number) => void,
|
||||
): Command {
|
||||
const rpc = new Command("rpc")
|
||||
.description("Manage the local RPC runtime server")
|
||||
.exitOverride()
|
||||
.allowExcessArguments()
|
||||
.argument("[subcommand]");
|
||||
|
||||
const addressOption = (description?: string) =>
|
||||
`--address <host:port>${description ? ` ${description}` : ""}`;
|
||||
|
||||
rpc
|
||||
.command("ensure")
|
||||
.description("Ensure the RPC runtime is running")
|
||||
.option(addressOption(), "RPC server address", DEFAULT_RPC_ADDRESS)
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async function (this: Command) {
|
||||
const opts = this.opts<{ address: string; json?: boolean }>();
|
||||
setExitCode(
|
||||
await runRpcEnsureCommand(
|
||||
{ address: opts.address, json: opts.json },
|
||||
io.writeln,
|
||||
io.writeErr,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
rpc
|
||||
.command("register")
|
||||
.description("Register an RPC client")
|
||||
.option(addressOption(), "RPC server address", DEFAULT_RPC_ADDRESS)
|
||||
.option("--client-id <id>", "Client ID")
|
||||
.option("--client-type <type>", "Client type", "desktop")
|
||||
.option(
|
||||
"--meta <key=value>",
|
||||
"Metadata entry (repeatable)",
|
||||
collectMeta,
|
||||
[],
|
||||
)
|
||||
.action(async function (this: Command) {
|
||||
const opts = this.opts<{
|
||||
address: string;
|
||||
clientId?: string;
|
||||
clientType: string;
|
||||
meta: string[];
|
||||
}>();
|
||||
setExitCode(
|
||||
await runRpcRegisterCommand(
|
||||
{
|
||||
address: opts.address,
|
||||
clientType: opts.clientType,
|
||||
clientId: opts.clientId,
|
||||
meta: opts.meta,
|
||||
},
|
||||
io.writeln,
|
||||
io.writeErr,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
rpc
|
||||
.command("start")
|
||||
.description("Start the RPC server")
|
||||
.option(addressOption(), "RPC server address", DEFAULT_RPC_ADDRESS)
|
||||
.action(async function (this: Command) {
|
||||
const opts = this.opts<{ address: string }>();
|
||||
setExitCode(
|
||||
await runRpcStartCommand(
|
||||
{ address: opts.address },
|
||||
io.writeln,
|
||||
io.writeErr,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
rpc
|
||||
.command("status")
|
||||
.description("Show RPC server status")
|
||||
.option(addressOption(), "RPC server address", DEFAULT_RPC_ADDRESS)
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async function (this: Command) {
|
||||
const opts = this.opts<{ address: string; json?: boolean }>();
|
||||
setExitCode(
|
||||
await runRpcStatusCommand(
|
||||
{ address: opts.address, json: opts.json },
|
||||
io.writeln,
|
||||
io.writeErr,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
rpc
|
||||
.command("stop")
|
||||
.description("Stop the RPC server")
|
||||
.option(addressOption(), "RPC server address", DEFAULT_RPC_ADDRESS)
|
||||
.action(async function (this: Command) {
|
||||
const opts = this.opts<{ address: string }>();
|
||||
setExitCode(
|
||||
await runRpcStopCommand(
|
||||
{ address: opts.address },
|
||||
io.writeln,
|
||||
io.writeErr,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
rpc.action((subcommand?: string) => {
|
||||
if (subcommand) {
|
||||
io.writeErr(`unknown rpc subcommand "${subcommand}"`);
|
||||
setExitCode(1);
|
||||
}
|
||||
});
|
||||
|
||||
return rpc;
|
||||
}
|
||||
@@ -1,26 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createScheduleCommand } from "./schedule";
|
||||
|
||||
const mockListSchedules = vi.hoisted(() => vi.fn());
|
||||
const mockClientClose = vi.hoisted(() => vi.fn());
|
||||
const mockGetRpcServerHealth = vi.hoisted(() => vi.fn());
|
||||
const mockSendHubCommand = vi.hoisted(() => vi.fn());
|
||||
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@clinebot/rpc", () => ({
|
||||
RPC_BUILD_VERSION: "rpc-build-test",
|
||||
getRpcServerHealth: mockGetRpcServerHealth,
|
||||
RpcSessionClient: class {
|
||||
async listSchedules(input: unknown) {
|
||||
return mockListSchedules(input);
|
||||
}
|
||||
|
||||
close() {
|
||||
mockClientClose();
|
||||
}
|
||||
},
|
||||
vi.mock("@clinebot/hub", () => ({
|
||||
sendHubCommand: mockSendHubCommand,
|
||||
}));
|
||||
|
||||
vi.mock("./rpc", () => ({
|
||||
runRpcEnsureCommand: vi.fn(async () => 0),
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
ensureCliHubServer: mockEnsureCliHubServer,
|
||||
}));
|
||||
|
||||
async function runScheduleCommand(
|
||||
@@ -41,8 +30,11 @@ describe("runScheduleCommand list output", () => {
|
||||
});
|
||||
|
||||
it('prints "No schedules found." for empty non-json list output', async () => {
|
||||
mockGetRpcServerHealth.mockResolvedValue({ running: true });
|
||||
mockListSchedules.mockResolvedValue([]);
|
||||
mockEnsureCliHubServer.mockResolvedValue("ws://127.0.0.1:4319/hub");
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedules: [] },
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
@@ -58,17 +50,26 @@ describe("runScheduleCommand list output", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(["No schedules found."]);
|
||||
expect(mockListSchedules).toHaveBeenCalledWith({
|
||||
limit: 100,
|
||||
enabled: undefined,
|
||||
tags: undefined,
|
||||
});
|
||||
expect(mockClientClose).toHaveBeenCalledTimes(1);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{},
|
||||
{
|
||||
clientId: "clite-schedule",
|
||||
command: "schedule.list",
|
||||
payload: {
|
||||
limit: 100,
|
||||
enabled: undefined,
|
||||
tags: undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps JSON list output unchanged when --json is provided", async () => {
|
||||
mockGetRpcServerHealth.mockResolvedValue({ running: true });
|
||||
mockListSchedules.mockResolvedValue([]);
|
||||
mockEnsureCliHubServer.mockResolvedValue("ws://127.0.0.1:4319/hub");
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedules: [] },
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
@@ -84,6 +85,6 @@ describe("runScheduleCommand list output", () => {
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(["[]"]);
|
||||
expect(mockClientClose).toHaveBeenCalledTimes(1);
|
||||
expect(mockSendHubCommand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,233 +1,6 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { getRpcServerHealth, RpcSessionClient } from "@clinebot/rpc";
|
||||
import { Command } from "commander";
|
||||
import { runRpcEnsureCommand } from "./rpc";
|
||||
|
||||
interface CommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
function parseList(raw: string | undefined): string[] | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const out = raw
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function parseJsonObjectFlag(
|
||||
raw: string | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!raw?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("metadata JSON must be an object");
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function mergeScheduleDeliveryMetadata(
|
||||
base: Record<string, unknown> | undefined,
|
||||
delivery: {
|
||||
deliveryAdapter?: string;
|
||||
deliveryThread?: string;
|
||||
deliveryChannel?: string;
|
||||
deliveryBot?: string;
|
||||
},
|
||||
): Record<string, unknown> | undefined {
|
||||
const adapter = delivery.deliveryAdapter?.trim();
|
||||
const threadId = delivery.deliveryThread?.trim();
|
||||
const channelId = delivery.deliveryChannel?.trim();
|
||||
const userName = delivery.deliveryBot?.trim();
|
||||
if (!adapter && !threadId && !channelId && !userName) {
|
||||
return base;
|
||||
}
|
||||
const next = { ...(base ?? {}) };
|
||||
const existingDelivery =
|
||||
next.delivery &&
|
||||
typeof next.delivery === "object" &&
|
||||
!Array.isArray(next.delivery)
|
||||
? (next.delivery as Record<string, unknown>)
|
||||
: {};
|
||||
next.delivery = {
|
||||
...existingDelivery,
|
||||
...(adapter ? { adapter } : {}),
|
||||
...(threadId ? { threadId } : {}),
|
||||
...(channelId ? { channelId } : {}),
|
||||
...(userName ? { userName } : {}),
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeScheduleAutonomousMetadata(
|
||||
base: Record<string, unknown> | undefined,
|
||||
autonomous: {
|
||||
autonomous?: true;
|
||||
noAutonomous?: true;
|
||||
idleTimeout?: string;
|
||||
pollInterval?: string;
|
||||
},
|
||||
): Record<string, unknown> | undefined {
|
||||
const autonomousEnabled = !!autonomous.autonomous;
|
||||
const autonomousDisabled = !!autonomous.noAutonomous;
|
||||
const idleTimeoutSeconds = autonomous.idleTimeout;
|
||||
const pollIntervalSeconds = autonomous.pollInterval;
|
||||
if (
|
||||
!autonomousEnabled &&
|
||||
!autonomousDisabled &&
|
||||
!idleTimeoutSeconds &&
|
||||
!pollIntervalSeconds
|
||||
) {
|
||||
return base;
|
||||
}
|
||||
const next = { ...(base ?? {}) };
|
||||
const existingAutonomous =
|
||||
next.autonomous &&
|
||||
typeof next.autonomous === "object" &&
|
||||
!Array.isArray(next.autonomous)
|
||||
? (next.autonomous as Record<string, unknown>)
|
||||
: {};
|
||||
next.autonomous = {
|
||||
...existingAutonomous,
|
||||
...(autonomousEnabled ? { enabled: true } : {}),
|
||||
...(autonomousDisabled ? { enabled: false } : {}),
|
||||
...(idleTimeoutSeconds
|
||||
? { idleTimeoutSeconds: toPositiveInt(idleTimeoutSeconds, 60) }
|
||||
: {}),
|
||||
...(pollIntervalSeconds
|
||||
? { pollIntervalSeconds: toPositiveInt(pollIntervalSeconds, 5) }
|
||||
: {}),
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
function hasMetadataPatchOpts(opts: Record<string, unknown>): boolean {
|
||||
return (
|
||||
!!opts.metadataJson ||
|
||||
!!opts.deliveryAdapter ||
|
||||
!!opts.deliveryThread ||
|
||||
!!opts.deliveryChannel ||
|
||||
!!opts.deliveryBot ||
|
||||
!!opts.autonomous ||
|
||||
!!opts.noAutonomous ||
|
||||
!!opts.idleTimeout ||
|
||||
!!opts.pollInterval
|
||||
);
|
||||
}
|
||||
|
||||
function mergeScheduleMetadata(
|
||||
base: Record<string, unknown> | undefined,
|
||||
opts: {
|
||||
deliveryAdapter?: string;
|
||||
deliveryThread?: string;
|
||||
deliveryChannel?: string;
|
||||
deliveryBot?: string;
|
||||
autonomous?: true;
|
||||
noAutonomous?: true;
|
||||
idleTimeout?: string;
|
||||
pollInterval?: string;
|
||||
},
|
||||
): Record<string, unknown> | undefined {
|
||||
return mergeScheduleAutonomousMetadata(
|
||||
mergeScheduleDeliveryMetadata(base, opts),
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
function isJsonPath(path: string): boolean {
|
||||
return path.toLowerCase().endsWith(".json");
|
||||
}
|
||||
|
||||
function parseMode(raw: string | undefined): "act" | "plan" | undefined {
|
||||
if (raw === "act" || raw === "plan") {
|
||||
return raw;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function ensureSchedulerRpc(
|
||||
address: string,
|
||||
io: CommandIo,
|
||||
): Promise<{ ok: boolean; address: string }> {
|
||||
const current = await getRpcServerHealth(address);
|
||||
if (current?.running) {
|
||||
return { ok: true, address };
|
||||
}
|
||||
let ensuredAddress = address;
|
||||
const code = await runRpcEnsureCommand(
|
||||
{ address, json: true },
|
||||
(text) => {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { address?: string };
|
||||
if (typeof parsed.address === "string" && parsed.address.trim()) {
|
||||
ensuredAddress = parsed.address.trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON lines
|
||||
}
|
||||
},
|
||||
io.writeErr,
|
||||
);
|
||||
if (code !== 0) {
|
||||
return { ok: false, address };
|
||||
}
|
||||
return { ok: true, address: ensuredAddress };
|
||||
}
|
||||
|
||||
function emitJsonOrText(json: boolean, io: CommandIo, value: unknown): void {
|
||||
if (json) {
|
||||
io.writeln(JSON.stringify(value));
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
io.writeln(value);
|
||||
return;
|
||||
}
|
||||
io.writeln(JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
function toPositiveInt(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resolveAddress(address: string | undefined): string {
|
||||
return (address ?? process.env.CLINE_RPC_ADDRESS ?? "127.0.0.1:4317").trim();
|
||||
}
|
||||
|
||||
function addSharedOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.option("--address <host:port>", "RPC server address")
|
||||
.option("--json", "Output as JSON");
|
||||
}
|
||||
|
||||
function addDeliveryOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.option("--delivery-adapter <name>", "Delivery adapter name")
|
||||
.option("--delivery-bot <name>", "Delivery bot user name")
|
||||
.option("--delivery-channel <id>", "Delivery channel ID")
|
||||
.option("--delivery-thread <id>", "Delivery thread ID");
|
||||
}
|
||||
|
||||
function addAutonomousOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.option("--autonomous", "Enable autonomous mode")
|
||||
.option("--no-autonomous", "Disable autonomous mode")
|
||||
.option("--idle-timeout <seconds>", "Autonomous idle timeout in seconds")
|
||||
.option("--poll-interval <seconds>", "Autonomous poll interval in seconds");
|
||||
}
|
||||
import { registerScheduleCommands } from "./schedule/handlers";
|
||||
import type { CommandIo } from "./schedule/types";
|
||||
|
||||
export function createScheduleCommand(
|
||||
io: CommandIo,
|
||||
@@ -238,7 +11,6 @@ export function createScheduleCommand(
|
||||
actionExitCode = 1;
|
||||
};
|
||||
|
||||
/** Wrap an async action with error handling. */
|
||||
function action<T extends unknown[]>(
|
||||
fn: (...args: T) => Promise<void>,
|
||||
): (...args: T) => Promise<void> {
|
||||
@@ -259,615 +31,6 @@ export function createScheduleCommand(
|
||||
setExitCode(actionExitCode);
|
||||
});
|
||||
|
||||
// --- schedule active ---
|
||||
const activeCmd = schedule
|
||||
.command("active")
|
||||
.description("Show currently active executions");
|
||||
addSharedOptions(activeCmd);
|
||||
activeCmd.action(
|
||||
action(async () => {
|
||||
const opts = activeCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
const active = await client.getActiveScheduledExecutions();
|
||||
emitJsonOrText(!!opts.json, io, active);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- schedule create ---
|
||||
const createCmd = schedule
|
||||
.command("create")
|
||||
.description("Create a new schedule")
|
||||
.argument("<name>", "Schedule name")
|
||||
.requiredOption("--cron <pattern>", "Cron pattern")
|
||||
.requiredOption("--prompt <text>", "Task prompt")
|
||||
.requiredOption("--workspace <path>", "Workspace root path")
|
||||
.option("--created-by <name>", "Creator name")
|
||||
.option("--cwd <path>", "Working directory")
|
||||
.option("--disabled", "Create in disabled state")
|
||||
.option("--max-iterations <n>", "Maximum iterations")
|
||||
.option("--max-parallel <n>", "Max parallel executions", "1")
|
||||
.option("--metadata-json <json>", "Metadata as JSON object")
|
||||
.option("--mode <act|plan>", "Execution mode")
|
||||
.option("--model <model>", "Model to use", "openai/gpt-5.3-codex")
|
||||
.option("--provider <id>", "Provider ID", "cline")
|
||||
.option("--system-prompt <text>", "System prompt override")
|
||||
.option("--tags <list>", "Comma-separated tags")
|
||||
.option("--timeout <seconds>", "Timeout in seconds");
|
||||
addDeliveryOptions(createCmd);
|
||||
addAutonomousOptions(createCmd);
|
||||
addSharedOptions(createCmd);
|
||||
createCmd.action(
|
||||
action(async (name: string) => {
|
||||
const opts = createCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
const metadata = mergeScheduleMetadata(
|
||||
parseJsonObjectFlag(opts.metadataJson),
|
||||
opts,
|
||||
);
|
||||
const created = await client.createSchedule({
|
||||
name,
|
||||
cronPattern: opts.cron,
|
||||
prompt: opts.prompt,
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
mode: opts.mode === "plan" ? "plan" : "act",
|
||||
workspaceRoot: opts.workspace,
|
||||
cwd: opts.cwd,
|
||||
systemPrompt: opts.systemPrompt,
|
||||
maxIterations: opts.maxIterations
|
||||
? toPositiveInt(opts.maxIterations, 1)
|
||||
: undefined,
|
||||
timeoutSeconds: opts.timeout
|
||||
? toPositiveInt(opts.timeout, 1)
|
||||
: undefined,
|
||||
maxParallel: toPositiveInt(opts.maxParallel, 1),
|
||||
enabled: !opts.disabled,
|
||||
createdBy: opts.createdBy,
|
||||
tags: parseList(opts.tags),
|
||||
metadata,
|
||||
});
|
||||
if (!created) {
|
||||
io.writeErr("failed to create schedule");
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, created);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- schedule delete ---
|
||||
const deleteCmd = schedule
|
||||
.command("delete")
|
||||
.description("Delete a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID");
|
||||
addSharedOptions(deleteCmd);
|
||||
deleteCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = deleteCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
const deleted = await client.deleteSchedule(scheduleId);
|
||||
emitJsonOrText(!!opts.json, io, { deleted });
|
||||
if (!deleted) fail();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- schedule export ---
|
||||
const exportCmd = schedule
|
||||
.command("export")
|
||||
.description("Export a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID")
|
||||
.option("--to <path>", "Output file path");
|
||||
addSharedOptions(exportCmd);
|
||||
exportCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = exportCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
const result = await client.getSchedule(scheduleId);
|
||||
if (!result) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
if (opts.json || (opts.to && isJsonPath(opts.to))) {
|
||||
io.writeln(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const yaml = await import("yaml");
|
||||
io.writeln(yaml.stringify(result));
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- schedule get ---
|
||||
const getCmd = schedule
|
||||
.command("get")
|
||||
.description("Get a schedule by ID")
|
||||
.argument("<schedule-id>", "Schedule ID");
|
||||
addSharedOptions(getCmd);
|
||||
getCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = getCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
const result = await client.getSchedule(scheduleId);
|
||||
if (!result) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, result);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- schedule history ---
|
||||
const historyCmd = schedule
|
||||
.command("history")
|
||||
.description("Show execution history for a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID")
|
||||
.option("--limit <n>", "Maximum number of results", "20")
|
||||
.option("--status <status>", "Filter by execution status");
|
||||
addSharedOptions(historyCmd);
|
||||
historyCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = historyCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
const executions = await client.listScheduleExecutions({
|
||||
scheduleId,
|
||||
status: opts.status,
|
||||
limit: toPositiveInt(opts.limit, 20),
|
||||
});
|
||||
emitJsonOrText(!!opts.json, io, executions);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- schedule import ---
|
||||
const importCmd = schedule
|
||||
.command("import")
|
||||
.description("Import a schedule from file")
|
||||
.argument("<path>", "Source file path");
|
||||
addSharedOptions(importCmd);
|
||||
importCmd.action(
|
||||
action(async (sourcePath: string) => {
|
||||
const opts = importCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
const sourceRaw = await readFile(sourcePath, "utf8");
|
||||
let parsed: Record<string, unknown>;
|
||||
if (isJsonPath(sourcePath)) {
|
||||
parsed = JSON.parse(sourceRaw) as Record<string, unknown>;
|
||||
} else {
|
||||
const yaml = await import("yaml");
|
||||
parsed = yaml.parse(sourceRaw) as Record<string, unknown>;
|
||||
}
|
||||
const workspaceRoot = String(
|
||||
parsed.workspaceRoot ?? parsed.workspace_root ?? "",
|
||||
).trim();
|
||||
if (!workspaceRoot) {
|
||||
io.writeErr(
|
||||
"schedule import requires workspaceRoot/workspace_root in the source file",
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const created = await client.createSchedule({
|
||||
name: String(parsed.name ?? "").trim(),
|
||||
cronPattern: String(parsed.cronPattern ?? parsed.cron ?? "").trim(),
|
||||
prompt: String(parsed.prompt ?? "").trim(),
|
||||
provider: String(parsed.provider ?? "cline").trim(),
|
||||
model: String(parsed.model ?? "openai/gpt-5.3-codex").trim(),
|
||||
mode: parsed.mode === "plan" ? "plan" : "act",
|
||||
workspaceRoot,
|
||||
cwd: String(parsed.cwd ?? "").trim() || undefined,
|
||||
systemPrompt:
|
||||
String(parsed.systemPrompt ?? parsed.system_prompt ?? "").trim() ||
|
||||
undefined,
|
||||
maxIterations:
|
||||
typeof parsed.maxIterations === "number"
|
||||
? parsed.maxIterations
|
||||
: typeof parsed.max_iterations === "number"
|
||||
? parsed.max_iterations
|
||||
: undefined,
|
||||
timeoutSeconds:
|
||||
typeof parsed.timeoutSeconds === "number"
|
||||
? parsed.timeoutSeconds
|
||||
: typeof parsed.timeout_seconds === "number"
|
||||
? parsed.timeout_seconds
|
||||
: undefined,
|
||||
maxParallel:
|
||||
typeof parsed.maxParallel === "number"
|
||||
? parsed.maxParallel
|
||||
: typeof parsed.max_parallel === "number"
|
||||
? parsed.max_parallel
|
||||
: 1,
|
||||
enabled: parsed.enabled !== false,
|
||||
createdBy:
|
||||
String(parsed.createdBy ?? parsed.created_by ?? "").trim() ||
|
||||
undefined,
|
||||
tags: Array.isArray(parsed.tags)
|
||||
? parsed.tags
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter((item) => item.length > 0)
|
||||
: undefined,
|
||||
metadata: mergeScheduleMetadata(
|
||||
parsed.metadata && typeof parsed.metadata === "object"
|
||||
? (parsed.metadata as Record<string, unknown>)
|
||||
: undefined,
|
||||
opts,
|
||||
),
|
||||
});
|
||||
if (!created) {
|
||||
io.writeErr("failed to import schedule");
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, created);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- schedule list ---
|
||||
const listCmd = schedule
|
||||
.command("list")
|
||||
.description("List schedules")
|
||||
.option("--disabled", "Show only disabled schedules")
|
||||
.option("--enabled", "Show only enabled schedules")
|
||||
.option("--limit <n>", "Maximum number of results", "100")
|
||||
.option("--tags <list>", "Filter by comma-separated tags");
|
||||
addSharedOptions(listCmd);
|
||||
listCmd.action(
|
||||
action(async () => {
|
||||
const opts = listCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
const enabled = opts.enabled ? true : opts.disabled ? false : undefined;
|
||||
const schedules = await client.listSchedules({
|
||||
limit: toPositiveInt(opts.limit, 100),
|
||||
enabled,
|
||||
tags: parseList(opts.tags),
|
||||
});
|
||||
if (!opts.json && Array.isArray(schedules) && schedules.length === 0) {
|
||||
io.writeln("No schedules found.");
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, schedules);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- schedule pause ---
|
||||
const pauseCmd = schedule
|
||||
.command("pause")
|
||||
.description("Pause a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID");
|
||||
addSharedOptions(pauseCmd);
|
||||
pauseCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = pauseCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
const result = await client.pauseSchedule(scheduleId);
|
||||
if (!result) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, result);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- schedule resume ---
|
||||
const resumeCmd = schedule
|
||||
.command("resume")
|
||||
.description("Resume a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID");
|
||||
addSharedOptions(resumeCmd);
|
||||
resumeCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = resumeCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
const result = await client.resumeSchedule(scheduleId);
|
||||
if (!result) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, result);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- schedule stats ---
|
||||
const statsCmd = schedule
|
||||
.command("stats")
|
||||
.description("Show statistics for a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID");
|
||||
addSharedOptions(statsCmd);
|
||||
statsCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = statsCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
const stats = await client.getScheduleStats(scheduleId);
|
||||
emitJsonOrText(!!opts.json, io, stats);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- schedule trigger ---
|
||||
const triggerCmd = schedule
|
||||
.command("trigger")
|
||||
.description("Trigger a schedule immediately")
|
||||
.argument("<schedule-id>", "Schedule ID");
|
||||
addSharedOptions(triggerCmd);
|
||||
triggerCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = triggerCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
const execution = await client.triggerScheduleNow(scheduleId);
|
||||
if (!execution) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, execution);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- schedule upcoming ---
|
||||
const upcomingCmd = schedule
|
||||
.command("upcoming")
|
||||
.description("Show upcoming scheduled runs")
|
||||
.option("--limit <n>", "Maximum number of results", "20");
|
||||
addSharedOptions(upcomingCmd);
|
||||
upcomingCmd.action(
|
||||
action(async () => {
|
||||
const opts = upcomingCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
const runs = await client.getUpcomingScheduledRuns(
|
||||
toPositiveInt(opts.limit, 20),
|
||||
);
|
||||
emitJsonOrText(!!opts.json, io, runs);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- schedule update ---
|
||||
const updateCmd = schedule
|
||||
.command("update")
|
||||
.description("Update a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID")
|
||||
.option("--clear-max-iterations", "Clear max iterations")
|
||||
.option("--clear-timeout", "Clear timeout")
|
||||
.option("--cron <pattern>", "New cron pattern")
|
||||
.option("--cwd <path>", "New working directory")
|
||||
.option("--disabled", "Disable the schedule")
|
||||
.option("--enabled", "Enable the schedule")
|
||||
.option("--max-iterations <n>", "New max iterations")
|
||||
.option("--max-parallel <n>", "New max parallel executions")
|
||||
.option("--metadata-json <json>", "New metadata as JSON object")
|
||||
.option("--mode <act|plan>", "New execution mode")
|
||||
.option("--model <model>", "New model")
|
||||
.option("--name <name>", "New name")
|
||||
.option("--pause", "Pause the schedule")
|
||||
.option("--prompt <text>", "New prompt")
|
||||
.option("--provider <id>", "New provider ID")
|
||||
.option("--resume", "Resume the schedule")
|
||||
.option("--system-prompt <text>", "New system prompt")
|
||||
.option("--tags <list>", "New comma-separated tags")
|
||||
.option("--timeout <n>", "New timeout in seconds")
|
||||
.option("--workspace <path>", "New workspace root");
|
||||
addDeliveryOptions(updateCmd);
|
||||
addAutonomousOptions(updateCmd);
|
||||
addSharedOptions(updateCmd);
|
||||
updateCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = updateCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerRpc(address, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(`failed to ensure rpc server at ${address}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = new RpcSessionClient({ address: ensured.address });
|
||||
try {
|
||||
if (opts.pause) {
|
||||
const result = await client.pauseSchedule(scheduleId);
|
||||
emitJsonOrText(!!opts.json, io, result ?? { updated: false });
|
||||
if (!result) fail();
|
||||
return;
|
||||
}
|
||||
if (opts.resume) {
|
||||
const result = await client.resumeSchedule(scheduleId);
|
||||
emitJsonOrText(!!opts.json, io, result ?? { updated: false });
|
||||
if (!result) fail();
|
||||
return;
|
||||
}
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
if (hasMetadataPatchOpts(opts)) {
|
||||
const current = await client.getSchedule(scheduleId);
|
||||
if (!current) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const metadataBase = {
|
||||
...((current.metadata as Record<string, unknown> | undefined) ??
|
||||
{}),
|
||||
...(parseJsonObjectFlag(opts.metadataJson) ?? {}),
|
||||
};
|
||||
metadata = mergeScheduleMetadata(metadataBase, opts);
|
||||
}
|
||||
const updated = await client.updateSchedule(scheduleId, {
|
||||
name: opts.name,
|
||||
cronPattern: opts.cron,
|
||||
prompt: opts.prompt,
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
mode: parseMode(opts.mode),
|
||||
workspaceRoot: opts.workspace,
|
||||
cwd: opts.cwd,
|
||||
systemPrompt: opts.systemPrompt,
|
||||
maxIterations: opts.maxIterations
|
||||
? toPositiveInt(opts.maxIterations, 1)
|
||||
: opts.clearMaxIterations
|
||||
? null
|
||||
: undefined,
|
||||
timeoutSeconds: opts.timeout
|
||||
? toPositiveInt(opts.timeout, 1)
|
||||
: opts.clearTimeout
|
||||
? null
|
||||
: undefined,
|
||||
maxParallel: opts.maxParallel
|
||||
? toPositiveInt(opts.maxParallel, 1)
|
||||
: undefined,
|
||||
enabled: opts.enabled ? true : opts.disabled ? false : undefined,
|
||||
tags: opts.tags ? parseList(opts.tags) : undefined,
|
||||
metadata,
|
||||
});
|
||||
if (!updated) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, updated);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
registerScheduleCommands(schedule, io, fail, action);
|
||||
return schedule;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { sendHubCommand } from "@clinebot/hub";
|
||||
import { ensureCliHubServer } from "../../utils/hub-runtime";
|
||||
import type { CommandIo } from "./types";
|
||||
|
||||
export function parseHubAddress(address: string | undefined): {
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
} {
|
||||
const trimmed = address?.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
const [host, portRaw] = trimmed.split(":", 2);
|
||||
const port = Number.parseInt(portRaw ?? "", 10);
|
||||
return {
|
||||
host: host?.trim() || undefined,
|
||||
port: Number.isInteger(port) ? port : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class HubScheduleClient {
|
||||
constructor(
|
||||
private readonly endpoint: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
pathname?: string;
|
||||
},
|
||||
) {}
|
||||
|
||||
close(): void {}
|
||||
|
||||
private async command(
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const reply = await sendHubCommand(this.endpoint, {
|
||||
clientId: "clite-schedule",
|
||||
command: command as never,
|
||||
payload,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
|
||||
}
|
||||
return (reply.payload ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async getActiveScheduledExecutions() {
|
||||
return (await this.command("schedule.active")).executions;
|
||||
}
|
||||
|
||||
async createSchedule(payload: Record<string, unknown>) {
|
||||
return (await this.command("schedule.create", payload)).schedule;
|
||||
}
|
||||
|
||||
async deleteSchedule(scheduleId: string) {
|
||||
return (
|
||||
(await this.command("schedule.delete", { scheduleId })).deleted === true
|
||||
);
|
||||
}
|
||||
|
||||
async getSchedule(scheduleId: string) {
|
||||
return (await this.command("schedule.get", { scheduleId })).schedule;
|
||||
}
|
||||
|
||||
async listScheduleExecutions(payload: Record<string, unknown>) {
|
||||
return (await this.command("schedule.list_executions", payload)).executions;
|
||||
}
|
||||
|
||||
async listSchedules(payload: Record<string, unknown>) {
|
||||
return (await this.command("schedule.list", payload)).schedules;
|
||||
}
|
||||
|
||||
async pauseSchedule(scheduleId: string) {
|
||||
return (await this.command("schedule.disable", { scheduleId })).schedule;
|
||||
}
|
||||
|
||||
async resumeSchedule(scheduleId: string) {
|
||||
return (await this.command("schedule.enable", { scheduleId })).schedule;
|
||||
}
|
||||
|
||||
async getScheduleStats(scheduleId: string) {
|
||||
return (await this.command("schedule.stats", { scheduleId })).stats;
|
||||
}
|
||||
|
||||
async triggerScheduleNow(scheduleId: string) {
|
||||
return (await this.command("schedule.trigger", { scheduleId })).execution;
|
||||
}
|
||||
|
||||
async getUpcomingScheduledRuns(limit: number) {
|
||||
return (await this.command("schedule.upcoming", { limit })).runs;
|
||||
}
|
||||
|
||||
async updateSchedule(scheduleId: string, payload: Record<string, unknown>) {
|
||||
return (
|
||||
await this.command("schedule.update", {
|
||||
scheduleId,
|
||||
...payload,
|
||||
})
|
||||
).schedule;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSchedulerHub(
|
||||
address: string | undefined,
|
||||
workspaceRoot: string,
|
||||
io: CommandIo,
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
client: HubScheduleClient;
|
||||
}> {
|
||||
try {
|
||||
const endpoint = parseHubAddress(address);
|
||||
await ensureCliHubServer(workspaceRoot, endpoint);
|
||||
return {
|
||||
ok: true,
|
||||
client: new HubScheduleClient(endpoint),
|
||||
};
|
||||
} catch (error) {
|
||||
io.writeErr(error instanceof Error ? error.message : String(error));
|
||||
return {
|
||||
ok: false,
|
||||
client: new HubScheduleClient({}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CommandIo } from "./types";
|
||||
|
||||
export function parseList(raw: string | undefined): string[] | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const out = raw
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
export function parseJsonObjectFlag(
|
||||
raw: string | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!raw?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("metadata JSON must be an object");
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function toPositiveInt(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function mergeScheduleDeliveryMetadata(
|
||||
base: Record<string, unknown> | undefined,
|
||||
delivery: {
|
||||
deliveryAdapter?: string;
|
||||
deliveryThread?: string;
|
||||
deliveryChannel?: string;
|
||||
deliveryBot?: string;
|
||||
},
|
||||
): Record<string, unknown> | undefined {
|
||||
const adapter = delivery.deliveryAdapter?.trim();
|
||||
const threadId = delivery.deliveryThread?.trim();
|
||||
const channelId = delivery.deliveryChannel?.trim();
|
||||
const userName = delivery.deliveryBot?.trim();
|
||||
if (!adapter && !threadId && !channelId && !userName) {
|
||||
return base;
|
||||
}
|
||||
const next = { ...(base ?? {}) };
|
||||
const existingDelivery =
|
||||
next.delivery &&
|
||||
typeof next.delivery === "object" &&
|
||||
!Array.isArray(next.delivery)
|
||||
? (next.delivery as Record<string, unknown>)
|
||||
: {};
|
||||
next.delivery = {
|
||||
...existingDelivery,
|
||||
...(adapter ? { adapter } : {}),
|
||||
...(threadId ? { threadId } : {}),
|
||||
...(channelId ? { channelId } : {}),
|
||||
...(userName ? { userName } : {}),
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
export function mergeScheduleAutonomousMetadata(
|
||||
base: Record<string, unknown> | undefined,
|
||||
autonomous: {
|
||||
autonomous?: true;
|
||||
noAutonomous?: true;
|
||||
idleTimeout?: string;
|
||||
pollInterval?: string;
|
||||
},
|
||||
): Record<string, unknown> | undefined {
|
||||
const autonomousEnabled = !!autonomous.autonomous;
|
||||
const autonomousDisabled = !!autonomous.noAutonomous;
|
||||
const idleTimeoutSeconds = autonomous.idleTimeout;
|
||||
const pollIntervalSeconds = autonomous.pollInterval;
|
||||
if (
|
||||
!autonomousEnabled &&
|
||||
!autonomousDisabled &&
|
||||
!idleTimeoutSeconds &&
|
||||
!pollIntervalSeconds
|
||||
) {
|
||||
return base;
|
||||
}
|
||||
const next = { ...(base ?? {}) };
|
||||
const existingAutonomous =
|
||||
next.autonomous &&
|
||||
typeof next.autonomous === "object" &&
|
||||
!Array.isArray(next.autonomous)
|
||||
? (next.autonomous as Record<string, unknown>)
|
||||
: {};
|
||||
next.autonomous = {
|
||||
...existingAutonomous,
|
||||
...(autonomousEnabled ? { enabled: true } : {}),
|
||||
...(autonomousDisabled ? { enabled: false } : {}),
|
||||
...(idleTimeoutSeconds
|
||||
? { idleTimeoutSeconds: toPositiveInt(idleTimeoutSeconds, 60) }
|
||||
: {}),
|
||||
...(pollIntervalSeconds
|
||||
? { pollIntervalSeconds: toPositiveInt(pollIntervalSeconds, 5) }
|
||||
: {}),
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
export function hasMetadataPatchOpts(opts: Record<string, unknown>): boolean {
|
||||
return (
|
||||
!!opts.metadataJson ||
|
||||
!!opts.deliveryAdapter ||
|
||||
!!opts.deliveryThread ||
|
||||
!!opts.deliveryChannel ||
|
||||
!!opts.deliveryBot ||
|
||||
!!opts.autonomous ||
|
||||
!!opts.noAutonomous ||
|
||||
!!opts.idleTimeout ||
|
||||
!!opts.pollInterval
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeScheduleMetadata(
|
||||
base: Record<string, unknown> | undefined,
|
||||
opts: {
|
||||
deliveryAdapter?: string;
|
||||
deliveryThread?: string;
|
||||
deliveryChannel?: string;
|
||||
deliveryBot?: string;
|
||||
autonomous?: true;
|
||||
noAutonomous?: true;
|
||||
idleTimeout?: string;
|
||||
pollInterval?: string;
|
||||
},
|
||||
): Record<string, unknown> | undefined {
|
||||
return mergeScheduleAutonomousMetadata(
|
||||
mergeScheduleDeliveryMetadata(base, opts),
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
export function isJsonPath(path: string): boolean {
|
||||
return path.toLowerCase().endsWith(".json");
|
||||
}
|
||||
|
||||
export function parseMode(raw: string | undefined): "act" | "plan" | undefined {
|
||||
if (raw === "act" || raw === "plan") {
|
||||
return raw;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function emitJsonOrText(
|
||||
json: boolean,
|
||||
io: CommandIo,
|
||||
value: unknown,
|
||||
): void {
|
||||
if (json) {
|
||||
io.writeln(JSON.stringify(value));
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
io.writeln(value);
|
||||
return;
|
||||
}
|
||||
io.writeln(JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
export function resolveAddress(
|
||||
address: string | undefined,
|
||||
): string | undefined {
|
||||
const resolved = address ?? process.env.CLINE_HUB_ADDRESS;
|
||||
const trimmed = resolved?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function formatResolvedAddressLabel(
|
||||
address: string | undefined,
|
||||
): string {
|
||||
return address ? ` at ${address}` : "";
|
||||
}
|
||||
|
||||
export function addSharedOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.option("--address <host:port>", "Hub server address")
|
||||
.option("--json", "Output as JSON");
|
||||
}
|
||||
|
||||
export function addDeliveryOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.option("--delivery-adapter <name>", "Delivery adapter name")
|
||||
.option("--delivery-bot <name>", "Delivery bot user name")
|
||||
.option("--delivery-channel <id>", "Delivery channel ID")
|
||||
.option("--delivery-thread <id>", "Delivery thread ID");
|
||||
}
|
||||
|
||||
export function addAutonomousOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.option("--autonomous", "Enable autonomous mode")
|
||||
.option("--no-autonomous", "Disable autonomous mode")
|
||||
.option("--idle-timeout <seconds>", "Autonomous idle timeout in seconds")
|
||||
.option("--poll-interval <seconds>", "Autonomous poll interval in seconds");
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
addAutonomousOptions,
|
||||
addDeliveryOptions,
|
||||
addSharedOptions,
|
||||
emitJsonOrText,
|
||||
formatResolvedAddressLabel,
|
||||
mergeScheduleMetadata,
|
||||
parseJsonObjectFlag,
|
||||
parseList,
|
||||
resolveAddress,
|
||||
toPositiveInt,
|
||||
} from "./common";
|
||||
import {
|
||||
registerScheduleExportCommand,
|
||||
registerScheduleImportCommand,
|
||||
registerScheduleUpdateCommand,
|
||||
} from "./import-export";
|
||||
import type { CommandIo, ScheduleActionWrapper } from "./types";
|
||||
|
||||
export function registerScheduleCommands(
|
||||
schedule: Command,
|
||||
io: CommandIo,
|
||||
fail: () => void,
|
||||
action: ScheduleActionWrapper,
|
||||
): void {
|
||||
const activeCmd = schedule
|
||||
.command("active")
|
||||
.description("Show currently active executions");
|
||||
addSharedOptions(activeCmd);
|
||||
activeCmd.action(
|
||||
action(async () => {
|
||||
const opts = activeCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, process.cwd(), io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
const active = await client.getActiveScheduledExecutions();
|
||||
emitJsonOrText(!!opts.json, io, active);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const createCmd = schedule
|
||||
.command("create")
|
||||
.description("Create a new schedule")
|
||||
.argument("<name>", "Schedule name")
|
||||
.requiredOption("--cron <pattern>", "Cron pattern")
|
||||
.requiredOption("--prompt <text>", "Task prompt")
|
||||
.requiredOption("--workspace <path>", "Workspace root path")
|
||||
.option("--created-by <name>", "Creator name")
|
||||
.option("--cwd <path>", "Working directory")
|
||||
.option("--disabled", "Create in disabled state")
|
||||
.option("--max-iterations <n>", "Maximum iterations")
|
||||
.option("--max-parallel <n>", "Max parallel executions", "1")
|
||||
.option("--metadata-json <json>", "Metadata as JSON object")
|
||||
.option("--mode <act|plan>", "Execution mode")
|
||||
.option("--model <model>", "Model to use", "openai/gpt-5.3-codex")
|
||||
.option("--provider <id>", "Provider ID", "cline")
|
||||
.option("--system-prompt <text>", "System prompt override")
|
||||
.option("--tags <list>", "Comma-separated tags")
|
||||
.option("--timeout <seconds>", "Timeout in seconds");
|
||||
addDeliveryOptions(createCmd);
|
||||
addAutonomousOptions(createCmd);
|
||||
addSharedOptions(createCmd);
|
||||
createCmd.action(
|
||||
action(async (name: string) => {
|
||||
const opts = createCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, opts.workspace, io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
const metadata = mergeScheduleMetadata(
|
||||
parseJsonObjectFlag(opts.metadataJson),
|
||||
opts,
|
||||
);
|
||||
const created = await client.createSchedule({
|
||||
name,
|
||||
cronPattern: opts.cron,
|
||||
prompt: opts.prompt,
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
mode: opts.mode === "plan" ? "plan" : "act",
|
||||
workspaceRoot: opts.workspace,
|
||||
cwd: opts.cwd,
|
||||
systemPrompt: opts.systemPrompt,
|
||||
maxIterations: opts.maxIterations
|
||||
? toPositiveInt(opts.maxIterations, 1)
|
||||
: undefined,
|
||||
timeoutSeconds: opts.timeout
|
||||
? toPositiveInt(opts.timeout, 1)
|
||||
: undefined,
|
||||
maxParallel: toPositiveInt(opts.maxParallel, 1),
|
||||
enabled: !opts.disabled,
|
||||
createdBy: opts.createdBy,
|
||||
tags: parseList(opts.tags),
|
||||
metadata,
|
||||
});
|
||||
if (!created) {
|
||||
io.writeErr("failed to create schedule");
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, created);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const deleteCmd = schedule
|
||||
.command("delete")
|
||||
.description("Delete a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID");
|
||||
addSharedOptions(deleteCmd);
|
||||
deleteCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = deleteCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, process.cwd(), io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
const deleted = await client.deleteSchedule(scheduleId);
|
||||
emitJsonOrText(!!opts.json, io, { deleted });
|
||||
if (!deleted) fail();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const getCmd = schedule
|
||||
.command("get")
|
||||
.description("Get a schedule by ID")
|
||||
.argument("<schedule-id>", "Schedule ID");
|
||||
addSharedOptions(getCmd);
|
||||
getCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = getCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, process.cwd(), io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
const result = await client.getSchedule(scheduleId);
|
||||
if (!result) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, result);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const historyCmd = schedule
|
||||
.command("history")
|
||||
.description("Show execution history for a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID")
|
||||
.option("--limit <n>", "Maximum number of results", "20")
|
||||
.option("--status <status>", "Filter by execution status");
|
||||
addSharedOptions(historyCmd);
|
||||
historyCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = historyCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, process.cwd(), io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
const executions = await client.listScheduleExecutions({
|
||||
scheduleId,
|
||||
status: opts.status,
|
||||
limit: toPositiveInt(opts.limit, 20),
|
||||
});
|
||||
emitJsonOrText(!!opts.json, io, executions);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const listCmd = schedule
|
||||
.command("list")
|
||||
.description("List schedules")
|
||||
.option("--disabled", "Show only disabled schedules")
|
||||
.option("--enabled", "Show only enabled schedules")
|
||||
.option("--limit <n>", "Maximum number of results", "100")
|
||||
.option("--tags <list>", "Filter by comma-separated tags");
|
||||
addSharedOptions(listCmd);
|
||||
listCmd.action(
|
||||
action(async () => {
|
||||
const opts = listCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, process.cwd(), io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
const enabled = opts.enabled ? true : opts.disabled ? false : undefined;
|
||||
const schedules = await client.listSchedules({
|
||||
limit: toPositiveInt(opts.limit, 100),
|
||||
enabled,
|
||||
tags: parseList(opts.tags),
|
||||
});
|
||||
if (!opts.json && Array.isArray(schedules) && schedules.length === 0) {
|
||||
io.writeln("No schedules found.");
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, schedules);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const pauseCmd = schedule
|
||||
.command("pause")
|
||||
.description("Pause a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID");
|
||||
addSharedOptions(pauseCmd);
|
||||
pauseCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = pauseCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, process.cwd(), io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
const result = await client.pauseSchedule(scheduleId);
|
||||
if (!result) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, result);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const resumeCmd = schedule
|
||||
.command("resume")
|
||||
.description("Resume a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID");
|
||||
addSharedOptions(resumeCmd);
|
||||
resumeCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = resumeCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, process.cwd(), io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
const result = await client.resumeSchedule(scheduleId);
|
||||
if (!result) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, result);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const statsCmd = schedule
|
||||
.command("stats")
|
||||
.description("Show statistics for a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID");
|
||||
addSharedOptions(statsCmd);
|
||||
statsCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = statsCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, process.cwd(), io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
const stats = await client.getScheduleStats(scheduleId);
|
||||
emitJsonOrText(!!opts.json, io, stats);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const triggerCmd = schedule
|
||||
.command("trigger")
|
||||
.description("Trigger a schedule immediately")
|
||||
.argument("<schedule-id>", "Schedule ID");
|
||||
addSharedOptions(triggerCmd);
|
||||
triggerCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = triggerCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, process.cwd(), io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
const execution = await client.triggerScheduleNow(scheduleId);
|
||||
if (!execution) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, execution);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const upcomingCmd = schedule
|
||||
.command("upcoming")
|
||||
.description("Show upcoming scheduled runs")
|
||||
.option("--limit <n>", "Maximum number of results", "20");
|
||||
addSharedOptions(upcomingCmd);
|
||||
upcomingCmd.action(
|
||||
action(async () => {
|
||||
const opts = upcomingCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, process.cwd(), io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
const runs = await client.getUpcomingScheduledRuns(
|
||||
toPositiveInt(opts.limit, 20),
|
||||
);
|
||||
emitJsonOrText(!!opts.json, io, runs);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
registerScheduleExportCommand(schedule, io, fail, action);
|
||||
registerScheduleImportCommand(schedule, io, fail, action);
|
||||
registerScheduleUpdateCommand(schedule, io, fail, action);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
addAutonomousOptions,
|
||||
addDeliveryOptions,
|
||||
addSharedOptions,
|
||||
emitJsonOrText,
|
||||
formatResolvedAddressLabel,
|
||||
hasMetadataPatchOpts,
|
||||
isJsonPath,
|
||||
mergeScheduleMetadata,
|
||||
parseJsonObjectFlag,
|
||||
parseList,
|
||||
parseMode,
|
||||
resolveAddress,
|
||||
toPositiveInt,
|
||||
} from "./common";
|
||||
import type { CommandIo, ScheduleActionWrapper } from "./types";
|
||||
|
||||
export function registerScheduleExportCommand(
|
||||
schedule: Command,
|
||||
io: CommandIo,
|
||||
fail: () => void,
|
||||
action: ScheduleActionWrapper,
|
||||
): void {
|
||||
const exportCmd = schedule
|
||||
.command("export")
|
||||
.description("Export a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID")
|
||||
.option("--to <path>", "Output file path");
|
||||
addSharedOptions(exportCmd);
|
||||
exportCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = exportCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, process.cwd(), io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
const result = await client.getSchedule(scheduleId);
|
||||
if (!result) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
if (opts.json || (opts.to && isJsonPath(opts.to))) {
|
||||
io.writeln(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const yaml = await import("yaml");
|
||||
io.writeln(yaml.stringify(result));
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerScheduleImportCommand(
|
||||
schedule: Command,
|
||||
io: CommandIo,
|
||||
fail: () => void,
|
||||
action: ScheduleActionWrapper,
|
||||
): void {
|
||||
const importCmd = schedule
|
||||
.command("import")
|
||||
.description("Import a schedule from file")
|
||||
.argument("<path>", "Source file path");
|
||||
addSharedOptions(importCmd);
|
||||
importCmd.action(
|
||||
action(async (sourcePath: string) => {
|
||||
const opts = importCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, process.cwd(), io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
const sourceRaw = await readFile(sourcePath, "utf8");
|
||||
let parsed: Record<string, unknown>;
|
||||
if (isJsonPath(sourcePath)) {
|
||||
parsed = JSON.parse(sourceRaw) as Record<string, unknown>;
|
||||
} else {
|
||||
const yaml = await import("yaml");
|
||||
parsed = yaml.parse(sourceRaw) as Record<string, unknown>;
|
||||
}
|
||||
const workspaceRoot = String(
|
||||
parsed.workspaceRoot ?? parsed.workspace_root ?? "",
|
||||
).trim();
|
||||
if (!workspaceRoot) {
|
||||
io.writeErr(
|
||||
"schedule import requires workspaceRoot/workspace_root in the source file",
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const created = await client.createSchedule({
|
||||
name: String(parsed.name ?? "").trim(),
|
||||
cronPattern: String(parsed.cronPattern ?? parsed.cron ?? "").trim(),
|
||||
prompt: String(parsed.prompt ?? "").trim(),
|
||||
provider: String(parsed.provider ?? "cline").trim(),
|
||||
model: String(parsed.model ?? "openai/gpt-5.3-codex").trim(),
|
||||
mode: parsed.mode === "plan" ? "plan" : "act",
|
||||
workspaceRoot,
|
||||
cwd: String(parsed.cwd ?? "").trim() || undefined,
|
||||
systemPrompt:
|
||||
String(parsed.systemPrompt ?? parsed.system_prompt ?? "").trim() ||
|
||||
undefined,
|
||||
maxIterations:
|
||||
typeof parsed.maxIterations === "number"
|
||||
? parsed.maxIterations
|
||||
: typeof parsed.max_iterations === "number"
|
||||
? parsed.max_iterations
|
||||
: undefined,
|
||||
timeoutSeconds:
|
||||
typeof parsed.timeoutSeconds === "number"
|
||||
? parsed.timeoutSeconds
|
||||
: typeof parsed.timeout_seconds === "number"
|
||||
? parsed.timeout_seconds
|
||||
: undefined,
|
||||
maxParallel:
|
||||
typeof parsed.maxParallel === "number"
|
||||
? parsed.maxParallel
|
||||
: typeof parsed.max_parallel === "number"
|
||||
? parsed.max_parallel
|
||||
: 1,
|
||||
enabled: parsed.enabled !== false,
|
||||
createdBy:
|
||||
String(parsed.createdBy ?? parsed.created_by ?? "").trim() ||
|
||||
undefined,
|
||||
tags: Array.isArray(parsed.tags)
|
||||
? parsed.tags
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter((item) => item.length > 0)
|
||||
: undefined,
|
||||
metadata: mergeScheduleMetadata(
|
||||
parsed.metadata && typeof parsed.metadata === "object"
|
||||
? (parsed.metadata as Record<string, unknown>)
|
||||
: undefined,
|
||||
opts,
|
||||
),
|
||||
});
|
||||
if (!created) {
|
||||
io.writeErr("failed to import schedule");
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, created);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerScheduleUpdateCommand(
|
||||
schedule: Command,
|
||||
io: CommandIo,
|
||||
fail: () => void,
|
||||
action: ScheduleActionWrapper,
|
||||
): void {
|
||||
const updateCmd = schedule
|
||||
.command("update")
|
||||
.description("Update a schedule")
|
||||
.argument("<schedule-id>", "Schedule ID")
|
||||
.option("--clear-max-iterations", "Clear max iterations")
|
||||
.option("--clear-timeout", "Clear timeout")
|
||||
.option("--cron <pattern>", "New cron pattern")
|
||||
.option("--cwd <path>", "New working directory")
|
||||
.option("--disabled", "Disable the schedule")
|
||||
.option("--enabled", "Enable the schedule")
|
||||
.option("--max-iterations <n>", "New max iterations")
|
||||
.option("--max-parallel <n>", "New max parallel executions")
|
||||
.option("--metadata-json <json>", "New metadata as JSON object")
|
||||
.option("--mode <act|plan>", "New execution mode")
|
||||
.option("--model <model>", "New model")
|
||||
.option("--name <name>", "New name")
|
||||
.option("--pause", "Pause the schedule")
|
||||
.option("--prompt <text>", "New prompt")
|
||||
.option("--provider <id>", "New provider ID")
|
||||
.option("--resume", "Resume the schedule")
|
||||
.option("--system-prompt <text>", "New system prompt")
|
||||
.option("--tags <list>", "New comma-separated tags")
|
||||
.option("--timeout <n>", "New timeout in seconds")
|
||||
.option("--workspace <path>", "New workspace root");
|
||||
addDeliveryOptions(updateCmd);
|
||||
addAutonomousOptions(updateCmd);
|
||||
addSharedOptions(updateCmd);
|
||||
updateCmd.action(
|
||||
action(async (scheduleId: string) => {
|
||||
const opts = updateCmd.opts();
|
||||
const address = resolveAddress(opts.address);
|
||||
const ensured = await ensureSchedulerHub(address, process.cwd(), io);
|
||||
if (!ensured.ok) {
|
||||
io.writeErr(
|
||||
`failed to ensure hub server${formatResolvedAddressLabel(address)}`,
|
||||
);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const client = ensured.client;
|
||||
try {
|
||||
if (opts.pause) {
|
||||
const result = await client.pauseSchedule(scheduleId);
|
||||
emitJsonOrText(!!opts.json, io, result ?? { updated: false });
|
||||
if (!result) fail();
|
||||
return;
|
||||
}
|
||||
if (opts.resume) {
|
||||
const result = await client.resumeSchedule(scheduleId);
|
||||
emitJsonOrText(!!opts.json, io, result ?? { updated: false });
|
||||
if (!result) fail();
|
||||
return;
|
||||
}
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
if (hasMetadataPatchOpts(opts)) {
|
||||
const current = (await client.getSchedule(scheduleId)) as
|
||||
| { metadata?: Record<string, unknown> }
|
||||
| undefined;
|
||||
if (!current) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const metadataBase = {
|
||||
...(current.metadata ?? {}),
|
||||
...(parseJsonObjectFlag(opts.metadataJson) ?? {}),
|
||||
};
|
||||
metadata = mergeScheduleMetadata(metadataBase, opts);
|
||||
}
|
||||
const updated = await client.updateSchedule(scheduleId, {
|
||||
name: opts.name,
|
||||
cronPattern: opts.cron,
|
||||
prompt: opts.prompt,
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
mode: parseMode(opts.mode),
|
||||
workspaceRoot: opts.workspace,
|
||||
cwd: opts.cwd,
|
||||
systemPrompt: opts.systemPrompt,
|
||||
maxIterations: opts.maxIterations
|
||||
? toPositiveInt(opts.maxIterations, 1)
|
||||
: opts.clearMaxIterations
|
||||
? null
|
||||
: undefined,
|
||||
timeoutSeconds: opts.timeout
|
||||
? toPositiveInt(opts.timeout, 1)
|
||||
: opts.clearTimeout
|
||||
? null
|
||||
: undefined,
|
||||
maxParallel: opts.maxParallel
|
||||
? toPositiveInt(opts.maxParallel, 1)
|
||||
: undefined,
|
||||
enabled: opts.enabled ? true : opts.disabled ? false : undefined,
|
||||
tags: opts.tags ? parseList(opts.tags) : undefined,
|
||||
metadata,
|
||||
});
|
||||
if (!updated) {
|
||||
io.writeErr(`schedule not found: ${scheduleId}`);
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
emitJsonOrText(!!opts.json, io, updated);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface CommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
export type ScheduleActionWrapper = <T extends unknown[]>(
|
||||
fn: (...args: T) => Promise<void>,
|
||||
) => (...args: T) => Promise<void>;
|
||||
@@ -2,17 +2,20 @@ import {
|
||||
createDiscordAdapter,
|
||||
type DiscordAdapter,
|
||||
} from "@chat-adapter/discord";
|
||||
import type { RpcChatStartSessionRequest } from "@clinebot/core";
|
||||
import type { ChatStartSessionRequest } from "@clinebot/core";
|
||||
import { createUserInstructionConfigWatcher } from "@clinebot/core";
|
||||
import { RpcSessionClient, registerRpcClient } from "@clinebot/rpc";
|
||||
import { HubSessionClient } from "@clinebot/hub";
|
||||
import type {
|
||||
ConnectDiscordOptions,
|
||||
DiscordConnectorState,
|
||||
} from "@clinebot/shared";
|
||||
import { Chat, ConsoleLogger, type Thread, ThreadImpl } from "chat";
|
||||
import type { Command } from "commander";
|
||||
import { ensureRpcRuntimeAddress } from "../../commands/rpc";
|
||||
import { createCliLoggerAdapter } from "../../logging/adapter";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
parseHubEndpointOverride,
|
||||
} from "../../utils/hub-runtime";
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
import {
|
||||
@@ -93,13 +96,12 @@ async function buildDiscordStartRequest(
|
||||
loggerConfig: Parameters<
|
||||
typeof buildConnectorStartRequest
|
||||
>[0]["loggerConfig"],
|
||||
): Promise<RpcChatStartSessionRequest> {
|
||||
): Promise<ChatStartSessionRequest> {
|
||||
return buildConnectorStartRequest({
|
||||
options,
|
||||
io,
|
||||
loggerConfig,
|
||||
systemRules: DISCORD_SYSTEM_RULES,
|
||||
teamName: `discord-${options.userName.replace(/[^a-zA-Z0-9_-]+/g, "-")}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,7 +147,7 @@ function resolveDiscordParticipant(
|
||||
async function persistDiscordThreadContext(input: {
|
||||
thread: Thread<DiscordThreadState>;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: RpcChatStartSessionRequest;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
rawMessage: unknown;
|
||||
errorLabel: string;
|
||||
}): Promise<void> {
|
||||
@@ -178,7 +180,7 @@ async function persistDiscordThreadContext(input: {
|
||||
|
||||
async function deliverScheduledResult(input: {
|
||||
bot: Chat;
|
||||
client: RpcSessionClient;
|
||||
client: HubSessionClient;
|
||||
bindingsPath: string;
|
||||
userName: string;
|
||||
scheduleId: string;
|
||||
@@ -535,21 +537,25 @@ class DiscordConnector extends ConnectorBase<
|
||||
cwd: commandCwd,
|
||||
workspaceRoot: startRequest.workspaceRoot || commandCwd,
|
||||
});
|
||||
const rpcAddress = await ensureRpcRuntimeAddress(options.rpcAddress);
|
||||
process.env.CLINE_RPC_ADDRESS = rpcAddress;
|
||||
const rpcAddress = await ensureCliHubServer(
|
||||
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
|
||||
parseHubEndpointOverride(options.rpcAddress),
|
||||
);
|
||||
|
||||
const clientId = `discord-${process.pid}-${Date.now()}`;
|
||||
await registerRpcClient(rpcAddress, {
|
||||
const client = new HubSessionClient({
|
||||
address: rpcAddress,
|
||||
clientId,
|
||||
clientType: "cli",
|
||||
displayName: "discord connector",
|
||||
workspaceRoot: startRequest.workspaceRoot || startRequest.cwd,
|
||||
cwd: startRequest.cwd,
|
||||
metadata: {
|
||||
transport: "discord",
|
||||
applicationId: options.applicationId,
|
||||
userName: options.userName,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
|
||||
const client = new RpcSessionClient({ address: rpcAddress });
|
||||
});
|
||||
this.writeConnectorState(statePath, {
|
||||
userName: options.userName,
|
||||
applicationId: options.applicationId,
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { createGoogleChatAdapter } from "@chat-adapter/gchat";
|
||||
import type { RpcChatStartSessionRequest } from "@clinebot/core";
|
||||
import type { ChatStartSessionRequest } from "@clinebot/core";
|
||||
import { createUserInstructionConfigWatcher } from "@clinebot/core";
|
||||
import { RpcSessionClient, registerRpcClient } from "@clinebot/rpc";
|
||||
import { HubSessionClient } from "@clinebot/hub";
|
||||
import type {
|
||||
ConnectGoogleChatOptions,
|
||||
GoogleChatConnectorState,
|
||||
} from "@clinebot/shared";
|
||||
import { Chat, ConsoleLogger, type Thread } from "chat";
|
||||
import type { Command } from "commander";
|
||||
import { ensureRpcRuntimeAddress } from "../../commands/rpc";
|
||||
import type { CliLoggerAdapter } from "../../logging/adapter";
|
||||
import { createCliLoggerAdapter } from "../../logging/adapter";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
parseHubEndpointOverride,
|
||||
} from "../../utils/hub-runtime";
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
import {
|
||||
@@ -84,13 +87,12 @@ async function buildGoogleChatStartRequest(
|
||||
loggerConfig: Parameters<
|
||||
typeof buildConnectorStartRequest
|
||||
>[0]["loggerConfig"],
|
||||
): Promise<RpcChatStartSessionRequest> {
|
||||
): Promise<ChatStartSessionRequest> {
|
||||
return buildConnectorStartRequest({
|
||||
options,
|
||||
io,
|
||||
loggerConfig,
|
||||
systemRules: GCHAT_SYSTEM_RULES,
|
||||
teamName: `gchat-${options.userName.replace(/[^a-zA-Z0-9_-]+/g, "-")}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,7 +129,7 @@ function resolveGoogleChatParticipant(
|
||||
async function persistGoogleChatThreadContext(input: {
|
||||
thread: Thread<GoogleChatThreadState>;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: RpcChatStartSessionRequest;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
rawMessage: unknown;
|
||||
errorLabel: string;
|
||||
}): Promise<void> {
|
||||
@@ -160,7 +162,7 @@ async function persistGoogleChatThreadContext(input: {
|
||||
|
||||
async function deliverScheduledResult(input: {
|
||||
bot: Chat;
|
||||
client: RpcSessionClient;
|
||||
client: HubSessionClient;
|
||||
logger: CliLoggerAdapter;
|
||||
bindingsPath: string;
|
||||
userName: string;
|
||||
@@ -541,20 +543,24 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
cwd: commandCwd,
|
||||
workspaceRoot: startRequest.workspaceRoot || commandCwd,
|
||||
});
|
||||
const rpcAddress = await ensureRpcRuntimeAddress(options.rpcAddress);
|
||||
process.env.CLINE_RPC_ADDRESS = rpcAddress;
|
||||
const rpcAddress = await ensureCliHubServer(
|
||||
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
|
||||
parseHubEndpointOverride(options.rpcAddress),
|
||||
);
|
||||
|
||||
const clientId = `gchat-${process.pid}-${Date.now()}`;
|
||||
await registerRpcClient(rpcAddress, {
|
||||
const client = new HubSessionClient({
|
||||
address: rpcAddress,
|
||||
clientId,
|
||||
clientType: "cli",
|
||||
displayName: "gchat connector",
|
||||
workspaceRoot: startRequest.workspaceRoot || startRequest.cwd,
|
||||
cwd: startRequest.cwd,
|
||||
metadata: {
|
||||
transport: "gchat",
|
||||
userName: options.userName,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
|
||||
const client = new RpcSessionClient({ address: rpcAddress });
|
||||
});
|
||||
this.writeConnectorState(statePath, {
|
||||
userName: options.userName,
|
||||
pid: process.pid,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import type { RpcChatStartSessionRequest } from "@clinebot/core";
|
||||
import type { ChatStartSessionRequest } from "@clinebot/core";
|
||||
import { createUserInstructionConfigWatcher } from "@clinebot/core";
|
||||
import { RpcSessionClient, registerRpcClient } from "@clinebot/rpc";
|
||||
import { HubSessionClient } from "@clinebot/hub";
|
||||
import type {
|
||||
ConnectLinearOptions,
|
||||
LinearConnectorState,
|
||||
} from "@clinebot/shared";
|
||||
import { type Adapter, Chat, ConsoleLogger, type Thread } from "chat";
|
||||
import type { Command } from "commander";
|
||||
import { ensureRpcRuntimeAddress } from "../../commands/rpc";
|
||||
import type { CliLoggerAdapter } from "../../logging/adapter";
|
||||
import { createCliLoggerAdapter } from "../../logging/adapter";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
parseHubEndpointOverride,
|
||||
} from "../../utils/hub-runtime";
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
import {
|
||||
@@ -107,7 +110,7 @@ async function buildLinearStartRequest(
|
||||
loggerConfig: Parameters<
|
||||
typeof buildConnectorStartRequest
|
||||
>[0]["loggerConfig"],
|
||||
): Promise<RpcChatStartSessionRequest> {
|
||||
): Promise<ChatStartSessionRequest> {
|
||||
return buildConnectorStartRequest({
|
||||
options: {
|
||||
...options,
|
||||
@@ -116,7 +119,6 @@ async function buildLinearStartRequest(
|
||||
io,
|
||||
loggerConfig,
|
||||
systemRules: LINEAR_SYSTEM_RULES,
|
||||
teamName: `linear-${options.userName.replace(/[^a-zA-Z0-9_-]+/g, "-")}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,7 +170,7 @@ function resolveLinearParticipant(
|
||||
async function persistLinearThreadContext(input: {
|
||||
thread: Thread<LinearThreadState>;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: RpcChatStartSessionRequest;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
rawMessage: unknown;
|
||||
errorLabel: string;
|
||||
}): Promise<void> {
|
||||
@@ -201,7 +203,7 @@ async function persistLinearThreadContext(input: {
|
||||
|
||||
async function deliverScheduledResult(input: {
|
||||
bot: Chat;
|
||||
client: RpcSessionClient;
|
||||
client: HubSessionClient;
|
||||
logger: CliLoggerAdapter;
|
||||
bindingsPath: string;
|
||||
userName: string;
|
||||
@@ -579,20 +581,24 @@ class LinearConnector extends ConnectorBase<
|
||||
cwd: commandCwd,
|
||||
workspaceRoot: startRequest.workspaceRoot || commandCwd,
|
||||
});
|
||||
const rpcAddress = await ensureRpcRuntimeAddress(options.rpcAddress);
|
||||
process.env.CLINE_RPC_ADDRESS = rpcAddress;
|
||||
const rpcAddress = await ensureCliHubServer(
|
||||
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
|
||||
parseHubEndpointOverride(options.rpcAddress),
|
||||
);
|
||||
|
||||
const clientId = `linear-${process.pid}-${Date.now()}`;
|
||||
await registerRpcClient(rpcAddress, {
|
||||
const client = new HubSessionClient({
|
||||
address: rpcAddress,
|
||||
clientId,
|
||||
clientType: "cli",
|
||||
displayName: "linear connector",
|
||||
workspaceRoot: startRequest.workspaceRoot || startRequest.cwd,
|
||||
cwd: startRequest.cwd,
|
||||
metadata: {
|
||||
transport: "linear",
|
||||
userName: options.userName,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
|
||||
const client = new RpcSessionClient({ address: rpcAddress });
|
||||
});
|
||||
this.writeConnectorState(statePath, {
|
||||
userName: options.userName,
|
||||
pid: process.pid,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSlackAdapter, type SlackAdapter } from "@chat-adapter/slack";
|
||||
import type { RpcChatStartSessionRequest } from "@clinebot/core";
|
||||
import type { ChatStartSessionRequest } from "@clinebot/core";
|
||||
import { createUserInstructionConfigWatcher } from "@clinebot/core";
|
||||
import { RpcSessionClient, registerRpcClient } from "@clinebot/rpc";
|
||||
import { HubSessionClient } from "@clinebot/hub";
|
||||
import type {
|
||||
ConnectSlackOptions,
|
||||
SlackConnectorState,
|
||||
@@ -14,9 +14,12 @@ import {
|
||||
ThreadImpl,
|
||||
} from "chat";
|
||||
import type { Command } from "commander";
|
||||
import { ensureRpcRuntimeAddress } from "../../commands/rpc";
|
||||
import type { CliLoggerAdapter } from "../../logging/adapter";
|
||||
import { createCliLoggerAdapter } from "../../logging/adapter";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
parseHubEndpointOverride,
|
||||
} from "../../utils/hub-runtime";
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
import {
|
||||
@@ -102,13 +105,12 @@ async function buildSlackStartRequest(
|
||||
loggerConfig: Parameters<
|
||||
typeof buildConnectorStartRequest
|
||||
>[0]["loggerConfig"],
|
||||
): Promise<RpcChatStartSessionRequest> {
|
||||
): Promise<ChatStartSessionRequest> {
|
||||
return buildConnectorStartRequest({
|
||||
options,
|
||||
io,
|
||||
loggerConfig,
|
||||
systemRules: SLACK_SYSTEM_RULES,
|
||||
teamName: `slack-${options.userName.replace(/[^a-zA-Z0-9_-]+/g, "-")}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -252,7 +254,7 @@ function clearSlackBinding(
|
||||
async function persistSlackThreadContext(input: {
|
||||
thread: Thread<SlackThreadState>;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: RpcChatStartSessionRequest;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
rawMessage: unknown;
|
||||
errorLabel: string;
|
||||
}): Promise<void> {
|
||||
@@ -289,7 +291,7 @@ async function persistSlackThreadContext(input: {
|
||||
async function deliverScheduledResult(input: {
|
||||
bot: Chat;
|
||||
slack: SlackAdapter;
|
||||
client: RpcSessionClient;
|
||||
client: HubSessionClient;
|
||||
logger: CliLoggerAdapter;
|
||||
bindingsPath: string;
|
||||
userName: string;
|
||||
@@ -672,20 +674,24 @@ class SlackConnector extends ConnectorBase<
|
||||
cwd: commandCwd,
|
||||
workspaceRoot: startRequest.workspaceRoot || commandCwd,
|
||||
});
|
||||
const rpcAddress = await ensureRpcRuntimeAddress(options.rpcAddress);
|
||||
process.env.CLINE_RPC_ADDRESS = rpcAddress;
|
||||
const rpcAddress = await ensureCliHubServer(
|
||||
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
|
||||
parseHubEndpointOverride(options.rpcAddress),
|
||||
);
|
||||
|
||||
const clientId = `slack-${process.pid}-${Date.now()}`;
|
||||
await registerRpcClient(rpcAddress, {
|
||||
const client = new HubSessionClient({
|
||||
address: rpcAddress,
|
||||
clientId,
|
||||
clientType: "cli",
|
||||
displayName: "slack connector",
|
||||
workspaceRoot: startRequest.workspaceRoot || startRequest.cwd,
|
||||
cwd: startRequest.cwd,
|
||||
metadata: {
|
||||
transport: "slack",
|
||||
userName: options.userName,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
|
||||
const client = new RpcSessionClient({ address: rpcAddress });
|
||||
});
|
||||
this.writeConnectorState(statePath, {
|
||||
userName: options.userName,
|
||||
pid: process.pid,
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { createTelegramAdapter } from "@chat-adapter/telegram";
|
||||
import type { RpcChatStartSessionRequest } from "@clinebot/core";
|
||||
import type { ChatStartSessionRequest } from "@clinebot/core";
|
||||
import { createUserInstructionConfigWatcher } from "@clinebot/core";
|
||||
import { RpcSessionClient, registerRpcClient } from "@clinebot/rpc";
|
||||
import { HubSessionClient } from "@clinebot/hub";
|
||||
import type {
|
||||
ConnectTelegramOptions,
|
||||
TelegramConnectorState,
|
||||
} from "@clinebot/shared";
|
||||
import { Chat, ConsoleLogger, type Thread } from "chat";
|
||||
import type { Command } from "commander";
|
||||
import { ensureRpcRuntimeAddress } from "../../commands/rpc";
|
||||
import type { CliLoggerAdapter } from "../../logging/adapter";
|
||||
import { createCliLoggerAdapter } from "../../logging/adapter";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
parseHubEndpointOverride,
|
||||
} from "../../utils/hub-runtime";
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
import { createChatSdkLogger, enqueueThreadTurn } from "../chat-runtime";
|
||||
@@ -83,13 +86,12 @@ async function buildTelegramStartRequest(
|
||||
loggerConfig: Parameters<
|
||||
typeof buildConnectorStartRequest
|
||||
>[0]["loggerConfig"],
|
||||
): Promise<RpcChatStartSessionRequest> {
|
||||
): Promise<ChatStartSessionRequest> {
|
||||
return buildConnectorStartRequest({
|
||||
options,
|
||||
io,
|
||||
loggerConfig,
|
||||
systemRules: TELEGRAM_SYSTEM_RULES,
|
||||
teamName: `telegram-${options.botUsername.replace(/[^a-zA-Z0-9_-]+/g, "-")}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,7 +143,7 @@ function resolveTelegramParticipant(
|
||||
async function persistTelegramThreadContext(input: {
|
||||
thread: Thread<TelegramThreadState>;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: RpcChatStartSessionRequest;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
rawMessage: unknown;
|
||||
errorLabel: string;
|
||||
}): Promise<void> {
|
||||
@@ -174,7 +176,7 @@ async function persistTelegramThreadContext(input: {
|
||||
|
||||
async function deliverScheduledResult(input: {
|
||||
bot: Chat;
|
||||
client: RpcSessionClient;
|
||||
client: HubSessionClient;
|
||||
logger: CliLoggerAdapter;
|
||||
bindingsPath: string;
|
||||
botUsername: string;
|
||||
@@ -551,20 +553,24 @@ class TelegramConnector extends ConnectorBase<
|
||||
cwd: commandCwd,
|
||||
workspaceRoot: startRequest.workspaceRoot || commandCwd,
|
||||
});
|
||||
const rpcAddress = await ensureRpcRuntimeAddress(options.rpcAddress);
|
||||
process.env.CLINE_RPC_ADDRESS = rpcAddress;
|
||||
const rpcAddress = await ensureCliHubServer(
|
||||
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
|
||||
parseHubEndpointOverride(options.rpcAddress),
|
||||
);
|
||||
|
||||
const clientId = `telegram-${process.pid}-${Date.now()}`;
|
||||
await registerRpcClient(rpcAddress, {
|
||||
const client = new HubSessionClient({
|
||||
address: rpcAddress,
|
||||
clientId,
|
||||
clientType: "cli",
|
||||
displayName: "telegram connector",
|
||||
workspaceRoot: startRequest.workspaceRoot || startRequest.cwd,
|
||||
cwd: startRequest.cwd,
|
||||
metadata: {
|
||||
transport: "telegram",
|
||||
botUserName: options.botUsername,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
|
||||
const client = new RpcSessionClient({ address: rpcAddress });
|
||||
});
|
||||
this.writeConnectorState(statePath, {
|
||||
botUsername: options.botUsername,
|
||||
pid: process.pid,
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { createWhatsAppAdapter } from "@chat-adapter/whatsapp";
|
||||
import type { RpcChatStartSessionRequest } from "@clinebot/core";
|
||||
import type { ChatStartSessionRequest } from "@clinebot/core";
|
||||
import { createUserInstructionConfigWatcher } from "@clinebot/core";
|
||||
import { RpcSessionClient, registerRpcClient } from "@clinebot/rpc";
|
||||
import { HubSessionClient } from "@clinebot/hub";
|
||||
import type {
|
||||
ConnectWhatsAppOptions,
|
||||
WhatsAppConnectorState,
|
||||
} from "@clinebot/shared";
|
||||
import { Chat, ConsoleLogger, type Thread } from "chat";
|
||||
import type { Command } from "commander";
|
||||
import { ensureRpcRuntimeAddress } from "../../commands/rpc";
|
||||
import type { CliLoggerAdapter } from "../../logging/adapter";
|
||||
import { createCliLoggerAdapter } from "../../logging/adapter";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
parseHubEndpointOverride,
|
||||
} from "../../utils/hub-runtime";
|
||||
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
|
||||
import { ConnectorBase } from "../base";
|
||||
import {
|
||||
@@ -104,17 +107,12 @@ async function buildWhatsAppStartRequest(
|
||||
loggerConfig: Parameters<
|
||||
typeof buildConnectorStartRequest
|
||||
>[0]["loggerConfig"],
|
||||
): Promise<RpcChatStartSessionRequest> {
|
||||
const instanceKey = resolveInstanceKey({
|
||||
phoneNumberId: options.phoneNumberId,
|
||||
userName: options.userName,
|
||||
});
|
||||
): Promise<ChatStartSessionRequest> {
|
||||
return buildConnectorStartRequest({
|
||||
options,
|
||||
io,
|
||||
loggerConfig,
|
||||
systemRules: WHATSAPP_SYSTEM_RULES,
|
||||
teamName: `whatsapp-${instanceKey.replace(/[^a-zA-Z0-9_-]+/g, "-")}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,7 +153,7 @@ function resolveWhatsAppParticipant(
|
||||
async function persistWhatsAppThreadContext(input: {
|
||||
thread: Thread<WhatsAppThreadState>;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: RpcChatStartSessionRequest;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
rawMessage: unknown;
|
||||
errorLabel: string;
|
||||
}): Promise<void> {
|
||||
@@ -188,7 +186,7 @@ async function persistWhatsAppThreadContext(input: {
|
||||
|
||||
async function deliverScheduledResult(input: {
|
||||
bot: Chat;
|
||||
client: RpcSessionClient;
|
||||
client: HubSessionClient;
|
||||
logger: CliLoggerAdapter;
|
||||
bindingsPath: string;
|
||||
options: ConnectWhatsAppOptions;
|
||||
@@ -547,13 +545,19 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
cwd: commandCwd,
|
||||
workspaceRoot: startRequest.workspaceRoot || commandCwd,
|
||||
});
|
||||
const rpcAddress = await ensureRpcRuntimeAddress(options.rpcAddress);
|
||||
process.env.CLINE_RPC_ADDRESS = rpcAddress;
|
||||
const rpcAddress = await ensureCliHubServer(
|
||||
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
|
||||
parseHubEndpointOverride(options.rpcAddress),
|
||||
);
|
||||
|
||||
const clientId = `whatsapp-${process.pid}-${Date.now()}`;
|
||||
await registerRpcClient(rpcAddress, {
|
||||
const client = new HubSessionClient({
|
||||
address: rpcAddress,
|
||||
clientId,
|
||||
clientType: "cli",
|
||||
displayName: "whatsapp connector",
|
||||
workspaceRoot: startRequest.workspaceRoot || startRequest.cwd,
|
||||
cwd: startRequest.cwd,
|
||||
metadata: {
|
||||
transport: "whatsapp",
|
||||
userName: options.userName,
|
||||
@@ -561,9 +565,7 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
? { phoneNumberId: options.phoneNumberId }
|
||||
: {}),
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
|
||||
const client = new RpcSessionClient({ address: rpcAddress });
|
||||
});
|
||||
this.writeConnectorState(statePath, {
|
||||
instanceKey,
|
||||
userName: options.userName,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { ensureParentDir, resolveClineDataDir } from "@clinebot/core";
|
||||
import type { RpcSessionClient, RpcSessionRow } from "@clinebot/rpc";
|
||||
import type { HubSessionClient, HubSessionRow } from "@clinebot/hub";
|
||||
import { withResolvedClineBuildEnv } from "@clinebot/shared";
|
||||
import { createCliLoggerAdapter } from "../logging/adapter";
|
||||
import { logSpawnedProcess } from "../logging/process";
|
||||
@@ -271,7 +271,7 @@ export function removeFile(path: string): void {
|
||||
|
||||
export function parseRowMetadata(
|
||||
row:
|
||||
| RpcSessionRow
|
||||
| HubSessionRow
|
||||
| {
|
||||
metadata?: Record<string, unknown>;
|
||||
parentSessionId?: string | null;
|
||||
@@ -295,7 +295,7 @@ export function parseLocalRowMetadata(row: {
|
||||
}
|
||||
|
||||
export async function readSessionReplyText(
|
||||
client: RpcSessionClient,
|
||||
client: HubSessionClient,
|
||||
sessionId: string,
|
||||
): Promise<string | undefined> {
|
||||
const session = await client.getSession(sessionId);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import type {
|
||||
RpcChatRunTurnRequest,
|
||||
RpcChatStartSessionRequest,
|
||||
ChatRunTurnRequest,
|
||||
ChatStartSessionRequest,
|
||||
UserInstructionConfigWatcher,
|
||||
} from "@clinebot/core";
|
||||
import type { RpcSessionClient } from "@clinebot/rpc";
|
||||
import type { HubSessionClient } from "@clinebot/hub";
|
||||
import type { SentMessage, Thread } from "chat";
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
import { buildUserInputMessage, resolveSystemPrompt } from "../runtime/prompt";
|
||||
@@ -37,14 +39,32 @@ export type ActiveConnectorTurn = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
function buildAttachments(input: {
|
||||
userImages: string[];
|
||||
userFiles: string[];
|
||||
}): ChatRunTurnRequest["attachments"] | undefined {
|
||||
const userImages = input.userImages.length > 0 ? input.userImages : undefined;
|
||||
const userFiles =
|
||||
input.userFiles.length > 0
|
||||
? input.userFiles.map((filePath) => ({
|
||||
name: basename(filePath),
|
||||
content: readFileSync(filePath, "utf8"),
|
||||
}))
|
||||
: undefined;
|
||||
if (!userImages && !userFiles) {
|
||||
return undefined;
|
||||
}
|
||||
return { userImages, userFiles };
|
||||
}
|
||||
|
||||
export async function handleConnectorUserTurn<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
thread: Thread<TState>;
|
||||
text: string;
|
||||
client: RpcSessionClient;
|
||||
client: HubSessionClient;
|
||||
pendingApprovals: Map<string, PendingConnectorApproval>;
|
||||
baseStartRequest: RpcChatStartSessionRequest;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
explicitSystemPrompt: string | undefined;
|
||||
clientId: string;
|
||||
logger: CliLoggerAdapter;
|
||||
@@ -82,7 +102,7 @@ export async function handleConnectorUserTurn<
|
||||
}) => Promise<void>;
|
||||
onDescribe?: (
|
||||
currentState: TState,
|
||||
baseStartRequest: RpcChatStartSessionRequest,
|
||||
baseStartRequest: ChatStartSessionRequest,
|
||||
thread: Thread<TState>,
|
||||
) => Promise<string> | string;
|
||||
onReplyCompleted?: (result: {
|
||||
@@ -527,12 +547,14 @@ export async function handleConnectorUserTurn<
|
||||
);
|
||||
const activeTurn = input.activeTurns?.get(turnKey);
|
||||
if (activeTurn?.sessionId?.trim()) {
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
resolvedInput,
|
||||
input.userInstructionWatcher,
|
||||
);
|
||||
await input.client.sendRuntimeSession(activeTurn.sessionId, {
|
||||
config: startRequest,
|
||||
prompt: await buildUserInputMessage(
|
||||
resolvedInput,
|
||||
input.userInstructionWatcher,
|
||||
),
|
||||
prompt,
|
||||
attachments: buildAttachments({ userImages, userFiles }),
|
||||
delivery: "steer",
|
||||
});
|
||||
await input.thread.post("Steering current task.");
|
||||
@@ -557,12 +579,14 @@ export async function handleConnectorUserTurn<
|
||||
reusedLogMessage: input.reusedLogMessage,
|
||||
startedLogMessage: input.startedLogMessage,
|
||||
});
|
||||
const request: RpcChatRunTurnRequest = {
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
resolvedInput,
|
||||
input.userInstructionWatcher,
|
||||
);
|
||||
const request: ChatRunTurnRequest = {
|
||||
config: startRequest,
|
||||
prompt: await buildUserInputMessage(
|
||||
resolvedInput,
|
||||
input.userInstructionWatcher,
|
||||
),
|
||||
prompt,
|
||||
attachments: buildAttachments({ userImages, userFiles }),
|
||||
};
|
||||
|
||||
input.activeTurns?.set(turnKey, { sessionId });
|
||||
@@ -631,7 +655,7 @@ export async function maybeHandleConnectorApprovalReply<
|
||||
>(input: {
|
||||
thread: Thread<TState>;
|
||||
text: string;
|
||||
client: RpcSessionClient;
|
||||
client: HubSessionClient;
|
||||
clientId: string;
|
||||
pendingApprovals: Map<string, PendingConnectorApproval>;
|
||||
deniedReason: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RpcChatRunTurnRequest } from "@clinebot/core";
|
||||
import type { RpcSessionClient } from "@clinebot/rpc";
|
||||
import type { ChatRunTurnRequest } from "@clinebot/core";
|
||||
import type { HubSessionClient } from "@clinebot/hub";
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
|
||||
export type PendingConnectorApproval = {
|
||||
@@ -133,9 +133,9 @@ function resolveTextDelta(
|
||||
}
|
||||
|
||||
export function createConnectorRuntimeTurnStream(input: {
|
||||
client: RpcSessionClient;
|
||||
client: HubSessionClient;
|
||||
sessionId: string;
|
||||
request: RpcChatRunTurnRequest;
|
||||
request: ChatRunTurnRequest;
|
||||
clientId: string;
|
||||
logger: CliLoggerAdapter;
|
||||
transport: string;
|
||||
|
||||
@@ -83,7 +83,6 @@ describe("buildConnectorStartRequest", () => {
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
teamName: "telegram-test",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("openrouter");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
RpcChatRuntimeLoggerConfig,
|
||||
RpcChatStartSessionRequest,
|
||||
ChatStartSessionRequest,
|
||||
RuntimeLoggerConfig,
|
||||
} from "@clinebot/core";
|
||||
import {
|
||||
CoreSessionService,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ProviderSettingsManager,
|
||||
SqliteSessionStore,
|
||||
} from "@clinebot/core";
|
||||
import { RpcSessionClient } from "@clinebot/rpc";
|
||||
import { HubSessionClient } from "@clinebot/hub";
|
||||
import type { Thread } from "chat";
|
||||
import {
|
||||
ensureOAuthProviderApiKey,
|
||||
@@ -58,11 +58,10 @@ export async function buildConnectorStartRequest(input: {
|
||||
enableTools: boolean;
|
||||
};
|
||||
io: ConnectIo;
|
||||
loggerConfig: RpcChatRuntimeLoggerConfig;
|
||||
loggerConfig: RuntimeLoggerConfig;
|
||||
systemRules: string;
|
||||
defaultModel?: string;
|
||||
teamName: string;
|
||||
}): Promise<RpcChatStartSessionRequest> {
|
||||
}): Promise<ChatStartSessionRequest> {
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings();
|
||||
@@ -118,19 +117,14 @@ export async function buildConnectorStartRequest(input: {
|
||||
logger: input.loggerConfig,
|
||||
maxIterations: input.options.maxIterations,
|
||||
enableTools: input.options.enableTools,
|
||||
enableSpawn: input.options.enableTools,
|
||||
enableTeams: input.options.enableTools,
|
||||
autoApproveTools: false,
|
||||
teamName: input.teamName,
|
||||
missionStepInterval: 3,
|
||||
missionTimeIntervalMs: 120000,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildThreadStartRequest<TState extends ConnectorThreadState>(
|
||||
base: RpcChatStartSessionRequest,
|
||||
base: ChatStartSessionRequest,
|
||||
state: TState,
|
||||
): RpcChatStartSessionRequest {
|
||||
): ChatStartSessionRequest {
|
||||
const enableTools = state.enableTools ?? base.enableTools;
|
||||
return {
|
||||
...base,
|
||||
@@ -148,8 +142,8 @@ export async function getOrCreateSessionId<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
thread: Thread<TState>;
|
||||
client: RpcSessionClient;
|
||||
startRequest: RpcChatStartSessionRequest;
|
||||
client: HubSessionClient;
|
||||
startRequest: ChatStartSessionRequest;
|
||||
logger: CliLoggerAdapter;
|
||||
clientId: string;
|
||||
transport: string;
|
||||
@@ -267,9 +261,9 @@ export async function getOrCreateSessionId<
|
||||
|
||||
export async function clearSession<TState extends ConnectorThreadState>(input: {
|
||||
thread: Thread<TState>;
|
||||
client: RpcSessionClient;
|
||||
client: HubSessionClient;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: RpcChatStartSessionRequest;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
errorLabel: string;
|
||||
}): Promise<void> {
|
||||
const threadState = await loadThreadState(
|
||||
@@ -302,7 +296,7 @@ export async function stopConnectorSessions(input: {
|
||||
localMatcher: (metadata: Record<string, unknown> | undefined) => boolean;
|
||||
rpcMatcher: (metadata: Record<string, unknown> | undefined) => boolean;
|
||||
}): Promise<number> {
|
||||
const client = new RpcSessionClient({ address: input.rpcAddress });
|
||||
const client = new HubSessionClient({ address: input.rpcAddress });
|
||||
try {
|
||||
const rows = await client.listSessions({ limit: 5000 });
|
||||
const filtered = rows.filter((row) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RpcSessionClient } from "@clinebot/rpc";
|
||||
import type { HubSessionClient } from "@clinebot/hub";
|
||||
import type { TeamProgressProjectionEvent } from "@clinebot/shared";
|
||||
import type { Chat, Thread } from "chat";
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
@@ -136,7 +136,7 @@ export function createTaskUpdateFingerprint(
|
||||
export function startConnectorTaskUpdateRelay<
|
||||
TState extends ConnectorThreadState,
|
||||
>(input: {
|
||||
client: RpcSessionClient;
|
||||
client: HubSessionClient;
|
||||
clientId: string;
|
||||
bot: Chat;
|
||||
logger: CliLoggerAdapter;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
truncateSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { BasicLogger, RpcChatRuntimeLoggerConfig } from "@clinebot/core";
|
||||
import type { BasicLogger, RuntimeLoggerConfig } from "@clinebot/core";
|
||||
import { resolveClineDataDir } from "@clinebot/core";
|
||||
import { registerDisposable } from "@clinebot/shared";
|
||||
import pino, {
|
||||
@@ -27,14 +27,14 @@ const LOG_CLEANUP_INTERVAL_MS = 2 * 24 * 60 * 60 * 1000;
|
||||
export interface CliLoggerAdapter {
|
||||
readonly pino: PinoLogger;
|
||||
readonly core: BasicLogger;
|
||||
readonly runtimeConfig: RpcChatRuntimeLoggerConfig;
|
||||
readonly runtimeConfig: RuntimeLoggerConfig;
|
||||
child(bindings: Record<string, unknown>): CliLoggerAdapter;
|
||||
}
|
||||
|
||||
interface CreateCliLoggerAdapterInput {
|
||||
runtime: "cli" | "rpc-runtime";
|
||||
component?: string;
|
||||
runtimeConfig?: RpcChatRuntimeLoggerConfig;
|
||||
runtimeConfig?: RuntimeLoggerConfig;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: ReadonlySet<LevelWithSilent> = new Set([
|
||||
@@ -57,8 +57,8 @@ function normalizeLogLevel(value: string | undefined): LevelWithSilent {
|
||||
|
||||
function normalizeRuntimeConfig(input: {
|
||||
runtime: "cli" | "rpc-runtime";
|
||||
runtimeConfig?: RpcChatRuntimeLoggerConfig;
|
||||
}): Required<RpcChatRuntimeLoggerConfig> {
|
||||
runtimeConfig?: RuntimeLoggerConfig;
|
||||
}): Required<RuntimeLoggerConfig> {
|
||||
const base = input.runtimeConfig;
|
||||
const defaultDestination = join(
|
||||
resolveClineDataDir(),
|
||||
@@ -90,7 +90,7 @@ function normalizeRuntimeConfig(input: {
|
||||
}
|
||||
|
||||
function getOrCreatePinoLogger(
|
||||
config: Required<RpcChatRuntimeLoggerConfig>,
|
||||
config: Required<RuntimeLoggerConfig>,
|
||||
runtime: "cli" | "rpc-runtime",
|
||||
): PinoLogger {
|
||||
if (!config.enabled) {
|
||||
@@ -266,7 +266,7 @@ function createCoreLogger(logger: PinoLogger): BasicLogger {
|
||||
|
||||
function createAdapterFromPino(
|
||||
logger: PinoLogger,
|
||||
runtimeConfig: Required<RpcChatRuntimeLoggerConfig>,
|
||||
runtimeConfig: Required<RuntimeLoggerConfig>,
|
||||
): CliLoggerAdapter {
|
||||
return {
|
||||
pino: logger,
|
||||
|
||||
@@ -65,6 +65,10 @@ const loggingMocks = vi.hoisted(() => ({
|
||||
})),
|
||||
flushCliLoggerAdapters: vi.fn(),
|
||||
}));
|
||||
const hubRuntimeMocks = vi.hoisted(() => ({
|
||||
ensureCliHubServer: vi.fn(async () => "ws://127.0.0.1:4317"),
|
||||
prewarmCliHubServer: vi.fn(),
|
||||
}));
|
||||
|
||||
function forcePromptModeInput() {
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
@@ -131,6 +135,7 @@ vi.mock("./runtime/prompt", () => ({
|
||||
vi.mock("./commands/history", () => historyMocks);
|
||||
vi.mock("./commands/checkpoint", () => checkpointMocks);
|
||||
vi.mock("./logging/adapter", () => loggingMocks);
|
||||
vi.mock("./utils/hub-runtime", () => hubRuntimeMocks);
|
||||
|
||||
describe("runCli lightweight command dispatch", () => {
|
||||
afterEach(() => {
|
||||
@@ -159,6 +164,9 @@ describe("runCli lightweight command dispatch", () => {
|
||||
mockState.runAgentCalls += 1;
|
||||
});
|
||||
runtimeMocks.runInteractive.mockReset();
|
||||
hubRuntimeMocks.ensureCliHubServer.mockReset();
|
||||
hubRuntimeMocks.ensureCliHubServer.mockResolvedValue("ws://127.0.0.1:4317");
|
||||
hubRuntimeMocks.prewarmCliHubServer.mockReset();
|
||||
authMocks.ensureOAuthProviderApiKey.mockReset();
|
||||
authMocks.getPersistedProviderApiKey.mockReset();
|
||||
authMocks.getPersistedProviderApiKey.mockReturnValue(undefined);
|
||||
@@ -244,6 +252,18 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
|
||||
it("skips hub prewarm for yolo runs", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--yolo", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
||||
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
|
||||
expect(hubRuntimeMocks.prewarmCliHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rewrites /team prompts and enables teams in single-prompt mode", async () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
|
||||
+122
-95
@@ -2,7 +2,6 @@ import { fstatSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename } from "node:path";
|
||||
import type { ToolPolicy } from "@clinebot/core";
|
||||
import { getRpcServerDefaultAddress } from "@clinebot/rpc";
|
||||
import { registerDisposable } from "@clinebot/shared";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
normalizeAutoApproveArgs,
|
||||
resolveWorkspaceRoot,
|
||||
} from "./utils/helpers";
|
||||
import { getInternalLaunchViolation } from "./utils/internal-launch";
|
||||
import {
|
||||
c,
|
||||
installStreamErrorGuards,
|
||||
@@ -30,12 +28,7 @@ import {
|
||||
isOAuthProvider,
|
||||
normalizeProviderId,
|
||||
} from "./utils/provider-auth";
|
||||
import { ensureCliRpcRuntimeAddress } from "./utils/rpc-runtime";
|
||||
import {
|
||||
enableTeamsForPrompt,
|
||||
rewriteTeamPrompt,
|
||||
TEAM_COMMAND_USAGE,
|
||||
} from "./utils/team-command";
|
||||
import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
|
||||
import type { Config } from "./utils/types";
|
||||
|
||||
export function stdinHasPipedInput(): boolean {
|
||||
@@ -48,17 +41,6 @@ export function stdinHasPipedInput(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function mergeToolPolicies(
|
||||
base: Record<string, ToolPolicy>,
|
||||
overrides: Record<string, ToolPolicy>,
|
||||
): Record<string, ToolPolicy> {
|
||||
const out: Record<string, ToolPolicy> = { ...base };
|
||||
for (const [name, policy] of Object.entries(overrides)) {
|
||||
out[name] = { ...(out[name] ?? {}), ...policy };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function createProviderSettingsManager() {
|
||||
const { ProviderSettingsManager } = await import("@clinebot/core");
|
||||
return new ProviderSettingsManager();
|
||||
@@ -82,6 +64,82 @@ async function loadInteractiveRuntimeModule() {
|
||||
return runInteractive;
|
||||
}
|
||||
|
||||
function resolveCwdArg(argv: string[]): string | undefined {
|
||||
const longIndex = argv.indexOf("--cwd");
|
||||
if (longIndex >= 0 && longIndex + 1 < argv.length) {
|
||||
const value = argv[longIndex + 1]?.trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] !== "-c") {
|
||||
continue;
|
||||
}
|
||||
const value = argv[index + 1]?.trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldPrewarmCliHub(argv: string[]): boolean {
|
||||
if (argv.includes("--yolo") || argv.includes("-y")) {
|
||||
return false;
|
||||
}
|
||||
const subcommand = argv.find((arg) => arg && !arg.startsWith("-"))?.trim();
|
||||
return subcommand !== "hub";
|
||||
}
|
||||
|
||||
function shouldAwaitCliHubPrewarm(argv: string[]): boolean {
|
||||
const firstPositional = argv
|
||||
.find((arg) => arg && !arg.startsWith("-"))
|
||||
?.trim();
|
||||
if (!firstPositional) {
|
||||
return false;
|
||||
}
|
||||
const knownSubcommands = new Set([
|
||||
"auth",
|
||||
"checkpoint",
|
||||
"config",
|
||||
"connect",
|
||||
"dev",
|
||||
"doctor",
|
||||
"history",
|
||||
"hook",
|
||||
"hub",
|
||||
"schedule",
|
||||
"task",
|
||||
"t",
|
||||
"update",
|
||||
"version",
|
||||
]);
|
||||
if (!knownSubcommands.has(firstPositional)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (firstPositional) {
|
||||
case "auth":
|
||||
case "checkpoint":
|
||||
case "config":
|
||||
case "connect":
|
||||
case "dev":
|
||||
case "doctor":
|
||||
case "history":
|
||||
case "hook":
|
||||
case "hub":
|
||||
case "schedule":
|
||||
case "task":
|
||||
case "t":
|
||||
case "update":
|
||||
case "version":
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-pass approach for --config: a quick scan of process.argv extracts the
|
||||
* config directory before commander parses, because setHomeDir() must run
|
||||
@@ -109,11 +167,25 @@ export async function runCli(): Promise<void> {
|
||||
let launchConfigView = false;
|
||||
|
||||
const normalizedArgs = normalizeAutoApproveArgs(cliArgs);
|
||||
const internalLaunchViolation = getInternalLaunchViolation(normalizedArgs);
|
||||
if (internalLaunchViolation) {
|
||||
writeErr(internalLaunchViolation);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
if (
|
||||
shouldPrewarmCliHub(normalizedArgs) &&
|
||||
process.env.CLINE_SESSION_BACKEND_MODE?.trim().toLowerCase() !== "local" &&
|
||||
!process.env.CLINE_VCR?.trim()
|
||||
) {
|
||||
const startupCwd = resolveCwdArg(normalizedArgs) ?? process.cwd();
|
||||
const startupWorkspaceRoot = resolveWorkspaceRoot(startupCwd);
|
||||
try {
|
||||
const { ensureCliHubServer, prewarmCliHubServer } = await import(
|
||||
"./utils/hub-runtime"
|
||||
);
|
||||
if (shouldAwaitCliHubPrewarm(normalizedArgs)) {
|
||||
await ensureCliHubServer(startupWorkspaceRoot);
|
||||
} else {
|
||||
prewarmCliHubServer(startupWorkspaceRoot);
|
||||
}
|
||||
} catch {
|
||||
// Defer hard failures to the command/runtime path; startup prewarm is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
// Subcommand routing via Commander
|
||||
@@ -202,15 +274,6 @@ export async function runCli(): Promise<void> {
|
||||
const realCmd = await createConfigRuntimeCommand();
|
||||
await realCmd.parseAsync(cmd.args, { from: "user" });
|
||||
});
|
||||
program
|
||||
.command("hook-worker")
|
||||
.allowUnknownOption()
|
||||
.allowExcessArguments()
|
||||
.action(async () => {
|
||||
const { runHookWorkerCommand } = await import("./commands/hook");
|
||||
ctx.exitCode = await runHookWorkerCommand(writeErr);
|
||||
});
|
||||
|
||||
const connectCmd = program
|
||||
.command("connect")
|
||||
.description("Connect to an editor or IDE adapter")
|
||||
@@ -237,18 +300,6 @@ export async function runCli(): Promise<void> {
|
||||
ctx.exitCode = await runStopAllConnectors(io);
|
||||
}
|
||||
} else if (adapter) {
|
||||
// Ensure the RPC server is running before starting the connector.
|
||||
// The connect command requires RPC for multi-client session management.
|
||||
if (!process.env.CLINE_RPC_ADDRESS?.trim()) {
|
||||
try {
|
||||
const rpcAddress = await ensureCliRpcRuntimeAddress(
|
||||
getRpcServerDefaultAddress(),
|
||||
);
|
||||
process.env.CLINE_RPC_ADDRESS = rpcAddress;
|
||||
} catch {
|
||||
// Best effort: proceed and let the connector handle any connection errors.
|
||||
}
|
||||
}
|
||||
// connectCmd.args = [adapter, ...passthroughFlags]. Pass only the
|
||||
// connector-specific flags (everything after the adapter name).
|
||||
ctx.exitCode = await runConnectAdapter(
|
||||
@@ -318,6 +369,9 @@ export async function runCli(): Promise<void> {
|
||||
const result = await runHistoryList({
|
||||
limit,
|
||||
outputMode,
|
||||
workspaceRoot: resolveWorkspaceRoot(
|
||||
program.opts().cwd ?? process.cwd(),
|
||||
),
|
||||
io,
|
||||
});
|
||||
if (typeof result === "string") {
|
||||
@@ -461,30 +515,18 @@ export async function runCli(): Promise<void> {
|
||||
ctx.exitCode = await runHookCommand(io);
|
||||
});
|
||||
|
||||
const createRpcRuntimeCommand = async () => {
|
||||
const { createRpcCommand } = await import("./commands/rpc");
|
||||
return createRpcCommand(io, (code) => {
|
||||
ctx.exitCode = code;
|
||||
});
|
||||
};
|
||||
|
||||
program
|
||||
.command("rpc")
|
||||
.description("Start or manage the RPC server")
|
||||
.allowUnknownOption()
|
||||
.allowExcessArguments()
|
||||
.passThroughOptions()
|
||||
.action(async (_opts: unknown, cmd: Command) => {
|
||||
const rpcCmd = await createRpcRuntimeCommand();
|
||||
await rpcCmd.parseAsync(cmd.args, { from: "user" });
|
||||
});
|
||||
|
||||
const createScheduleRuntimeCommand = async () => {
|
||||
const { createScheduleCommand } = await import("./commands/schedule");
|
||||
return createScheduleCommand(io, (code) => {
|
||||
ctx.exitCode = code;
|
||||
});
|
||||
};
|
||||
const createHubRuntimeCommand = async () => {
|
||||
const { createHubCommand } = await import("./commands/hub");
|
||||
return createHubCommand(io, (code) => {
|
||||
ctx.exitCode = code;
|
||||
});
|
||||
};
|
||||
|
||||
program
|
||||
.command("schedule")
|
||||
@@ -496,6 +538,16 @@ export async function runCli(): Promise<void> {
|
||||
const scheduleCmd = await createScheduleRuntimeCommand();
|
||||
await scheduleCmd.parseAsync(cmd.args, { from: "user" });
|
||||
});
|
||||
program
|
||||
.command("hub")
|
||||
.description("Manage the local hub daemon")
|
||||
.allowUnknownOption()
|
||||
.allowExcessArguments()
|
||||
.passThroughOptions()
|
||||
.action(async (_opts: unknown, cmd: Command) => {
|
||||
const hubCmd = await createHubRuntimeCommand();
|
||||
await hubCmd.parseAsync(cmd.args, { from: "user" });
|
||||
});
|
||||
|
||||
// 'task' is syntactic sugar for the default prompt flow.
|
||||
// Re-parse everything after 'task'/'t' through a fresh root program
|
||||
@@ -536,12 +588,6 @@ export async function runCli(): Promise<void> {
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.action(async () => {
|
||||
const address = process.env.CLINE_RPC_ADDRESS || "127.0.0.1:4317";
|
||||
import("@clinebot/rpc")
|
||||
.then(({ requestRpcServerShutdown }) =>
|
||||
requestRpcServerShutdown(address),
|
||||
)
|
||||
.catch(() => {});
|
||||
writeErr(
|
||||
"update command is not implemented yet (use your package manager to update manually)",
|
||||
);
|
||||
@@ -657,18 +703,11 @@ export async function runCli(): Promise<void> {
|
||||
}
|
||||
setCurrentOutputMode(args.outputMode);
|
||||
const defaultToolAutoApprove = args.defaultToolAutoApprove;
|
||||
const mergedToolPolicies = mergeToolPolicies({}, args.toolPolicies);
|
||||
const toolPolicies: Record<string, ToolPolicy> = {
|
||||
"*": {
|
||||
autoApprove: defaultToolAutoApprove,
|
||||
},
|
||||
};
|
||||
for (const [name, policy] of Object.entries(mergedToolPolicies)) {
|
||||
toolPolicies[name] = {
|
||||
enabled: policy.enabled,
|
||||
autoApprove: policy.autoApprove ?? defaultToolAutoApprove,
|
||||
};
|
||||
}
|
||||
|
||||
if (args.outputMode === "json" && (args.interactive || !args.prompt)) {
|
||||
writeErr(
|
||||
@@ -812,7 +851,6 @@ export async function runCli(): Promise<void> {
|
||||
sandbox: sandboxEnabled,
|
||||
sandboxDataDir,
|
||||
showUsage: args.showUsage,
|
||||
showTimings: args.showTimings,
|
||||
verbose: args.verbose,
|
||||
thinking: effectiveReasoningEffort !== "none",
|
||||
reasoningEffort:
|
||||
@@ -825,9 +863,9 @@ export async function runCli(): Promise<void> {
|
||||
loggerConfig: loggerAdapter.runtimeConfig,
|
||||
defaultToolAutoApprove,
|
||||
toolPolicies,
|
||||
enableSpawnAgent: args.enableSpawnAgent,
|
||||
enableAgentTeams: args.enableAgentTeams,
|
||||
enableTools: args.enableTools,
|
||||
enableSpawnAgent: args.yolo !== true,
|
||||
enableAgentTeams: args.yolo !== true,
|
||||
enableTools: true,
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
extensionContext: {
|
||||
@@ -841,19 +879,10 @@ export async function runCli(): Promise<void> {
|
||||
},
|
||||
logger: loggerAdapter.core,
|
||||
},
|
||||
teamName: args.enableAgentTeams
|
||||
? args.teamName?.trim() || createTeamName()
|
||||
: undefined,
|
||||
missionLogIntervalSteps:
|
||||
typeof args.missionLogIntervalSteps === "number" &&
|
||||
Number.isFinite(args.missionLogIntervalSteps)
|
||||
? args.missionLogIntervalSteps
|
||||
: 3,
|
||||
missionLogIntervalMs:
|
||||
typeof args.missionLogIntervalMs === "number" &&
|
||||
Number.isFinite(args.missionLogIntervalMs)
|
||||
? args.missionLogIntervalMs
|
||||
: 120000,
|
||||
teamName:
|
||||
args.yolo !== true
|
||||
? args.teamName?.trim() || createTeamName()
|
||||
: undefined,
|
||||
};
|
||||
try {
|
||||
// For OAuth providers, don't write the resolved key into apiKey —
|
||||
@@ -899,7 +928,6 @@ export async function runCli(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (rewrittenTeamPrompt.kind === "rewritten") {
|
||||
await enableTeamsForPrompt(config);
|
||||
await runAgent(
|
||||
rewrittenTeamPrompt.prompt,
|
||||
config,
|
||||
@@ -930,7 +958,6 @@ export async function runCli(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (rewrittenTeamPrompt.kind === "rewritten") {
|
||||
await enableTeamsForPrompt(config);
|
||||
await runAgent(
|
||||
rewrittenTeamPrompt.prompt,
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
|
||||
describe("buildUserInputMessage", () => {
|
||||
it("extracts image mentions into userImages", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cli-prompt-"));
|
||||
const imagePath = join(dir, "hero.png");
|
||||
writeFileSync(imagePath, Buffer.from("hello"));
|
||||
|
||||
const result = await buildUserInputMessage(
|
||||
`@${imagePath} describe this image`,
|
||||
);
|
||||
|
||||
expect(result.prompt).toBe("[image: hero.png] describe this image");
|
||||
expect(result.userImages).toEqual(["data:image/png;base64,aGVsbG8="]);
|
||||
expect(result.userFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts text file mentions into userFiles", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cli-prompt-"));
|
||||
const filePath = join(dir, "notes.md");
|
||||
writeFileSync(filePath, "# Notes\n");
|
||||
|
||||
const result = await buildUserInputMessage(`summarize @${filePath}`);
|
||||
|
||||
expect(result.prompt).toBe("summarize [file: notes.md]");
|
||||
expect(result.userImages).toEqual([]);
|
||||
expect(result.userFiles).toEqual([filePath]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { basename } from "node:path";
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, resolve } from "node:path";
|
||||
import {
|
||||
buildWorkspaceMetadata,
|
||||
mergeRulesForSystemPrompt,
|
||||
resolveRuntimeSlashCommandFromWatcher,
|
||||
type UserInstructionConfigWatcher,
|
||||
} from "@clinebot/core";
|
||||
@@ -19,7 +22,7 @@ export async function resolveSystemPrompt(input: {
|
||||
workspaceRoot: input.cwd,
|
||||
workspaceName: basename(input.cwd),
|
||||
metadata,
|
||||
rules: input.rules,
|
||||
rules: mergeRulesForSystemPrompt(undefined, input.rules),
|
||||
mode: input.mode,
|
||||
providerId: input.providerId,
|
||||
overridePrompt: input.explicitSystemPrompt,
|
||||
@@ -28,11 +31,199 @@ export async function resolveSystemPrompt(input: {
|
||||
});
|
||||
}
|
||||
|
||||
const FILE_MENTION_PATTERN_TEST = /@(?:\/|~\/|\.{1,2}\/)\S+/i;
|
||||
const FILE_MENTION_PATTERN_EXEC = /@((?:\/|~\/|\.{1,2}\/)\S+)/g;
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".svg",
|
||||
]);
|
||||
|
||||
function hasFileMentions(prompt: string): boolean {
|
||||
return FILE_MENTION_PATTERN_TEST.test(prompt);
|
||||
}
|
||||
|
||||
function extractFileMentions(
|
||||
prompt: string,
|
||||
): Array<{ path: string; index: number; raw: string }> {
|
||||
const matches: Array<{ path: string; index: number; raw: string }> = [];
|
||||
let match: RegExpExecArray | null;
|
||||
const pattern = new RegExp(
|
||||
FILE_MENTION_PATTERN_EXEC.source,
|
||||
FILE_MENTION_PATTERN_EXEC.flags,
|
||||
);
|
||||
|
||||
while ((match = pattern.exec(prompt)) !== null) {
|
||||
matches.push({
|
||||
path: match[1],
|
||||
index: match.index,
|
||||
raw: match[0],
|
||||
});
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function resolveMentionPath(filePath: string): string {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return resolve(homedir(), filePath.slice(2));
|
||||
}
|
||||
return resolve(filePath);
|
||||
}
|
||||
|
||||
function isImagePath(filePath: string): boolean {
|
||||
const normalized = filePath.toLowerCase();
|
||||
for (const extension of IMAGE_EXTENSIONS) {
|
||||
if (normalized.endsWith(extension)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the MIME type based on file extension
|
||||
*/
|
||||
function getMimeType(filePath: string): string {
|
||||
const ext = filePath.toLowerCase().split(".").pop() || "";
|
||||
const mimeTypes: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
svg: "image/svg+xml",
|
||||
};
|
||||
return mimeTypes[ext] || "image/png";
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an image file and converts it to base64
|
||||
*/
|
||||
function loadImageAsBase64(filePath: string): string {
|
||||
try {
|
||||
const buffer = readFileSync(filePath);
|
||||
return buffer.toString("base64");
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to load image from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses user input message and extracts @mentioned file references.
|
||||
* Image files are converted to data URLs for image content blocks.
|
||||
* Non-image files are forwarded as userFiles so the runtime can materialize
|
||||
* them as file content blocks.
|
||||
*/
|
||||
export async function buildUserInputMessage(
|
||||
rawPrompt: string,
|
||||
userInstructionWatcher?: UserInstructionConfigWatcher,
|
||||
): Promise<string> {
|
||||
return userInstructionWatcher
|
||||
? resolveRuntimeSlashCommandFromWatcher(rawPrompt, userInstructionWatcher)
|
||||
: rawPrompt;
|
||||
): Promise<{
|
||||
prompt: string;
|
||||
userImages: string[];
|
||||
userFiles: string[];
|
||||
}> {
|
||||
// First, resolve slash commands if watcher is available
|
||||
let prompt = rawPrompt;
|
||||
if (userInstructionWatcher) {
|
||||
prompt = await resolveRuntimeSlashCommandFromWatcher(
|
||||
rawPrompt,
|
||||
userInstructionWatcher,
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasFileMentions(prompt)) {
|
||||
return {
|
||||
prompt,
|
||||
userImages: [],
|
||||
userFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
const fileMentions = extractFileMentions(prompt);
|
||||
|
||||
if (fileMentions.length === 0) {
|
||||
return {
|
||||
prompt,
|
||||
userImages: [],
|
||||
userFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
fileMentions.sort((a, b) => b.index - a.index);
|
||||
|
||||
let processedPrompt = prompt;
|
||||
const userImages: string[] = [];
|
||||
const userFiles: string[] = [];
|
||||
const loadedImages: Array<{
|
||||
index: number;
|
||||
data: string;
|
||||
mediaType: string;
|
||||
fileName: string;
|
||||
}> = [];
|
||||
const loadedFiles: Array<{
|
||||
index: number;
|
||||
path: string;
|
||||
fileName: string;
|
||||
}> = [];
|
||||
|
||||
for (const mention of fileMentions) {
|
||||
try {
|
||||
const resolvedPath = resolveMentionPath(mention.path);
|
||||
const stats = statSync(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Path is not a file: ${resolvedPath}`);
|
||||
}
|
||||
const fileName = basename(resolvedPath);
|
||||
|
||||
if (isImagePath(resolvedPath)) {
|
||||
const data = loadImageAsBase64(resolvedPath);
|
||||
const mediaType = getMimeType(resolvedPath);
|
||||
loadedImages.push({
|
||||
index: mention.index,
|
||||
data,
|
||||
mediaType,
|
||||
fileName,
|
||||
});
|
||||
processedPrompt = processedPrompt.replace(
|
||||
mention.raw,
|
||||
`[image: ${fileName}]`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
loadedFiles.push({
|
||||
index: mention.index,
|
||||
path: resolvedPath,
|
||||
fileName,
|
||||
});
|
||||
processedPrompt = processedPrompt.replace(
|
||||
mention.raw,
|
||||
`[file: ${fileName}]`,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[warning] ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const image of loadedImages.reverse()) {
|
||||
const dataUrl = `data:${image.mediaType};base64,${image.data}`;
|
||||
userImages.push(dataUrl);
|
||||
}
|
||||
for (const file of loadedFiles.reverse()) {
|
||||
userFiles.push(file.path);
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: processedPrompt,
|
||||
userImages,
|
||||
userFiles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +69,11 @@ vi.mock("./interactive-welcome", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./prompt", () => ({
|
||||
buildUserInputMessage: vi.fn(async () => "prompt"),
|
||||
buildUserInputMessage: vi.fn(async () => ({
|
||||
prompt: "prompt",
|
||||
userImages: [],
|
||||
userFiles: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./session-events", () => ({
|
||||
@@ -157,7 +161,6 @@ describe("runAgent", () => {
|
||||
modelId: "google/gemini-3-flash-preview",
|
||||
outputMode: "text",
|
||||
providerId: "openrouter",
|
||||
showTimings: false,
|
||||
showUsage: false,
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
@@ -175,6 +178,70 @@ describe("runAgent", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("clears a stale failing exit code after a successful run", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
process.exitCode = 1;
|
||||
sessionManagerMocks.start.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: {
|
||||
session_id: "session-1",
|
||||
},
|
||||
result: {
|
||||
text: "ok",
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: undefined,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "stop",
|
||||
model: {
|
||||
id: "gemini",
|
||||
provider: "openrouter",
|
||||
info: {},
|
||||
},
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: {
|
||||
maxConsecutiveMistakes: 3,
|
||||
},
|
||||
logger: undefined,
|
||||
maxIterations: 10,
|
||||
mode: "act",
|
||||
modelId: "google/gemini-3-flash-preview",
|
||||
outputMode: "text",
|
||||
providerId: "openrouter",
|
||||
showUsage: false,
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("does not fail an aborted run when teardown hooks throw", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
@@ -231,7 +298,6 @@ describe("runAgent", () => {
|
||||
modelId: "google/gemini-3-flash-preview",
|
||||
outputMode: "text",
|
||||
providerId: "openrouter",
|
||||
showTimings: false,
|
||||
showUsage: false,
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
@@ -269,7 +335,6 @@ describe("runAgent", () => {
|
||||
modelId: "google/gemini-3-flash-preview",
|
||||
outputMode: "text",
|
||||
providerId: "openrouter",
|
||||
showTimings: false,
|
||||
showUsage: false,
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
|
||||
@@ -90,29 +90,25 @@ function printRunStats(
|
||||
return;
|
||||
}
|
||||
writeln();
|
||||
if (config.showTimings || config.showUsage) {
|
||||
if (config.showUsage) {
|
||||
const parts: string[] = [];
|
||||
if (config.showTimings) {
|
||||
parts.push(`${((performance.now() - startTime) / 1000).toFixed(2)}s`);
|
||||
parts.push(`${((performance.now() - startTime) / 1000).toFixed(2)}s`);
|
||||
const tokenParts: string[] = [
|
||||
`${usage.inputTokens} in`,
|
||||
`${usage.outputTokens} out`,
|
||||
];
|
||||
if (usage.cacheReadTokens) {
|
||||
tokenParts.push(`${usage.cacheReadTokens} cache read`);
|
||||
}
|
||||
if (config.showUsage) {
|
||||
const tokenParts: string[] = [
|
||||
`${usage.inputTokens} in`,
|
||||
`${usage.outputTokens} out`,
|
||||
];
|
||||
if (usage.cacheReadTokens) {
|
||||
tokenParts.push(`${usage.cacheReadTokens} cache read`);
|
||||
}
|
||||
if (usage.cacheWriteTokens) {
|
||||
tokenParts.push(`${usage.cacheWriteTokens} cache write`);
|
||||
}
|
||||
parts.push(tokenParts.join(", "));
|
||||
if (typeof usage.totalCost === "number") {
|
||||
parts.push(`${formatUsd(usage.totalCost)} est. cost`);
|
||||
}
|
||||
if (result.iterations > 1) {
|
||||
parts.push(`${result.iterations} iterations`);
|
||||
}
|
||||
if (usage.cacheWriteTokens) {
|
||||
tokenParts.push(`${usage.cacheWriteTokens} cache write`);
|
||||
}
|
||||
parts.push(tokenParts.join(", "));
|
||||
if (typeof usage.totalCost === "number") {
|
||||
parts.push(`${formatUsd(usage.totalCost)} est. cost`);
|
||||
}
|
||||
if (result.iterations > 1) {
|
||||
parts.push(`${result.iterations} iterations`);
|
||||
}
|
||||
writeln(`${c.dim}[${parts.join(" | ")}]${c.reset}`);
|
||||
}
|
||||
@@ -132,6 +128,10 @@ export async function runAgent(
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
},
|
||||
): Promise<void> {
|
||||
// A clean one-shot run should not inherit a stale nonzero process exit code
|
||||
// from lower layers or prior bookkeeping inside the same process.
|
||||
process.exitCode = 0;
|
||||
|
||||
if (config.verbose) {
|
||||
const clineWelcomeLine = await resolveClineWelcomeLine({
|
||||
config,
|
||||
@@ -146,10 +146,6 @@ export async function runAgent(
|
||||
const startTime = performance.now();
|
||||
void prewarmFileIndex(config.cwd);
|
||||
|
||||
const runtimeHooks = createRuntimeHooks({
|
||||
verbose: config.verbose,
|
||||
yolo: config.mode === "yolo",
|
||||
});
|
||||
const sessionManager = await createCliCore({
|
||||
defaultToolExecutors: {
|
||||
askQuestion: askQuestionInTerminal,
|
||||
@@ -160,6 +156,15 @@ export async function runAgent(
|
||||
toolPolicies: config.toolPolicies,
|
||||
requestToolApproval,
|
||||
});
|
||||
const runtimeHooks = createRuntimeHooks({
|
||||
verbose: config.verbose,
|
||||
yolo: config.mode === "yolo",
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
dispatchHookEvent: async (payload) => {
|
||||
await sessionManager.handleHookEvent(payload);
|
||||
},
|
||||
});
|
||||
|
||||
let reasoningChunkCount = 0;
|
||||
let redactedReasoningChunkCount = 0;
|
||||
@@ -231,10 +236,11 @@ export async function runAgent(
|
||||
if (config.verbose) {
|
||||
printModelProviderInfo(config);
|
||||
}
|
||||
const userInput = await buildUserInputMessage(
|
||||
prompt,
|
||||
userInstructionWatcher,
|
||||
);
|
||||
const {
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(prompt, userInstructionWatcher);
|
||||
const started = await sessionManager.start({
|
||||
source: SessionSource.CLI,
|
||||
config: {
|
||||
@@ -252,6 +258,8 @@ export async function runAgent(
|
||||
) => resolveMistakeLimitDecision(config, context),
|
||||
},
|
||||
prompt: userInput,
|
||||
userImages: userImages.length > 0 ? userImages : undefined,
|
||||
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
||||
interactive: false,
|
||||
localRuntime: {
|
||||
userInstructionWatcher,
|
||||
@@ -292,7 +300,12 @@ export async function runAgent(
|
||||
result = started.result;
|
||||
} else {
|
||||
result = await sessionManager
|
||||
.send({ sessionId: started.sessionId, prompt: userInput })
|
||||
.send({
|
||||
sessionId: started.sessionId,
|
||||
prompt: userInput,
|
||||
userImages: userImages.length > 0 ? userImages : undefined,
|
||||
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
||||
})
|
||||
.finally(clearRunTimeout);
|
||||
}
|
||||
if (!result) {
|
||||
@@ -342,6 +355,7 @@ export async function runAgent(
|
||||
reasoningChunkCount,
|
||||
redactedReasoningChunkCount,
|
||||
);
|
||||
process.exitCode = 0;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
config.logger?.log(err instanceof Error ? (err.stack ?? message) : message);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
prewarmFileIndex,
|
||||
SessionSource,
|
||||
type TeamEvent,
|
||||
toggleDisabledTool,
|
||||
type UserInstructionConfigWatcher,
|
||||
} from "@clinebot/core";
|
||||
import { render } from "ink";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { loadInteractiveConfigData } from "../tui/interactive-config";
|
||||
import { InteractiveTui } from "../tui/interactive-tui";
|
||||
import {
|
||||
type InteractiveSlashCommand,
|
||||
listInteractiveSlashCommands,
|
||||
resolveClineWelcomeLine,
|
||||
} from "../tui/interactive-welcome";
|
||||
@@ -26,12 +28,13 @@ import {
|
||||
} from "../utils/approval";
|
||||
import {
|
||||
type ChatCommandState,
|
||||
chatCommandHost,
|
||||
type ForkSessionResult,
|
||||
maybeHandleChatCommand,
|
||||
} from "../utils/chat-commands";
|
||||
import { createRuntimeHooks } from "../utils/hooks";
|
||||
import { c, setActiveCliSession, writeErr, writeln } from "../utils/output";
|
||||
import { createWorkspaceChatCommandHost } from "../utils/plugin-chat-commands";
|
||||
import { readRepoStatus } from "../utils/repo-status";
|
||||
import { loadInteractiveResumeMessages } from "../utils/resume";
|
||||
import {
|
||||
enableTeamsForPrompt,
|
||||
@@ -80,29 +83,27 @@ export async function runInteractive(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const initialRepoStatus = await readRepoStatus(config.cwd);
|
||||
void prewarmFileIndex(config.cwd);
|
||||
const workflowSlashCommands = listInteractiveSlashCommands(
|
||||
userInstructionWatcher,
|
||||
);
|
||||
const { host: chatCommandHost, pluginSlashCommands } =
|
||||
await createWorkspaceChatCommandHost({
|
||||
let interactiveChatCommandHost = chatCommandHost;
|
||||
const loadAdditionalSlashCommands = async (): Promise<
|
||||
InteractiveSlashCommand[]
|
||||
> => {
|
||||
const { host, pluginSlashCommands } = await createWorkspaceChatCommandHost({
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
logger: config.logger,
|
||||
});
|
||||
for (const cmd of pluginSlashCommands) {
|
||||
workflowSlashCommands.push({
|
||||
interactiveChatCommandHost = host;
|
||||
return pluginSlashCommands.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
instructions: "",
|
||||
description: cmd.description ?? "Plugin command",
|
||||
});
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const runtimeHooks = createRuntimeHooks({
|
||||
verbose: config.verbose,
|
||||
yolo: config.mode === "yolo",
|
||||
});
|
||||
const enableChatCommands = process.env.CLINE_ENABLE_CHAT_COMMANDS === "1";
|
||||
const autoApproveAllRef = {
|
||||
current: config.toolPolicies["*"]?.autoApprove !== false,
|
||||
@@ -117,61 +118,8 @@ export async function runInteractive(
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
const sessionManager = await createCliCore({
|
||||
defaultToolExecutors: {
|
||||
askQuestion: askQuestionInTerminal,
|
||||
submit: submitAndExitInTerminal,
|
||||
},
|
||||
forceLocalBackend: config.mode === "yolo" || config.sandbox === true,
|
||||
logger: config.logger,
|
||||
toolPolicies: config.toolPolicies,
|
||||
requestToolApproval: async (request) => {
|
||||
if (autoApproveAllRef.current) {
|
||||
return { approved: true };
|
||||
}
|
||||
return requestToolApproval(request);
|
||||
},
|
||||
});
|
||||
|
||||
const uiEvents = getUIEventEmitter();
|
||||
|
||||
const onAgentEvent = (event: AgentEvent): void => {
|
||||
uiEvents.emit("agent", event);
|
||||
};
|
||||
const unsubscribeAgent = subscribeToAgentEvents(sessionManager, onAgentEvent);
|
||||
const unsubscribePendingPrompts = subscribeToPendingPromptEvents(
|
||||
sessionManager,
|
||||
{
|
||||
onPendingPrompts: (event: PendingPromptSnapshot): void => {
|
||||
uiEvents.emit("pending-prompts", event);
|
||||
},
|
||||
onPendingPromptSubmitted: (event: PendingPromptSubmittedEvent): void => {
|
||||
uiEvents.emit("pending-prompt-submitted", event);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const initialMessages = await loadInteractiveResumeMessages(
|
||||
sessionManager,
|
||||
resumeSessionId,
|
||||
);
|
||||
if (resumeSessionId?.trim()) {
|
||||
const previewMessages = getLastSessionPreviewMessages(
|
||||
initialMessages ?? [],
|
||||
2,
|
||||
);
|
||||
if (previewMessages.length > 0) {
|
||||
writeln(
|
||||
`${c.dim}Resuming ${resumeSessionId.trim()} with recent context:${c.reset}`,
|
||||
);
|
||||
for (const previewMessage of previewMessages) {
|
||||
writeln(
|
||||
`${c.dim}${formatPreviewMessageText(previewMessage)}${c.reset}`,
|
||||
);
|
||||
}
|
||||
writeln();
|
||||
}
|
||||
}
|
||||
const chatCommandState: ChatCommandState = {
|
||||
enableTools: config.enableTools,
|
||||
autoApproveTools: autoApproveAllRef.current,
|
||||
@@ -228,13 +176,26 @@ export async function runInteractive(
|
||||
guidance: `mistake_limit_reached: ${answer.trim()}`,
|
||||
};
|
||||
};
|
||||
let sessionManager: Awaited<ReturnType<typeof createCliCore>> | undefined;
|
||||
let runtimeHooks: ReturnType<typeof createRuntimeHooks> | undefined;
|
||||
let unsubscribeAgent = () => {};
|
||||
let unsubscribePendingPrompts = () => {};
|
||||
let startupPromise: Promise<void> | undefined;
|
||||
let startupError: unknown;
|
||||
let shutdownRequested = false;
|
||||
// Tracks the session that is currently live for send/abort/stop operations.
|
||||
let activeSessionId = "";
|
||||
// One-time startup input: when present, the first interactive session
|
||||
// reuses this historical id instead of allocating a new one.
|
||||
const initialResumeSessionId = resumeSessionId?.trim() || undefined;
|
||||
const applyStartedSession = (
|
||||
started: Awaited<ReturnType<typeof sessionManager.start>>,
|
||||
started: NonNullable<
|
||||
Awaited<ReturnType<typeof createCliCore>>
|
||||
> extends infer T
|
||||
? T extends { start: (...args: never[]) => Promise<infer R> }
|
||||
? R
|
||||
: never
|
||||
: never,
|
||||
) => {
|
||||
setActiveCliSession({
|
||||
manifestPath: started.manifestPath,
|
||||
@@ -243,13 +204,67 @@ export async function runInteractive(
|
||||
});
|
||||
activeSessionId = started.sessionId;
|
||||
};
|
||||
const ensureSessionManager = async () => {
|
||||
if (sessionManager) {
|
||||
return sessionManager;
|
||||
}
|
||||
const manager = await createCliCore({
|
||||
defaultToolExecutors: {
|
||||
askQuestion: askQuestionInTerminal,
|
||||
submit: submitAndExitInTerminal,
|
||||
},
|
||||
forceLocalBackend: config.mode === "yolo" || config.sandbox === true,
|
||||
logger: config.logger,
|
||||
toolPolicies: config.toolPolicies,
|
||||
requestToolApproval: async (request) => {
|
||||
if (autoApproveAllRef.current) {
|
||||
return { approved: true };
|
||||
}
|
||||
return requestToolApproval(request);
|
||||
},
|
||||
});
|
||||
if (shutdownRequested) {
|
||||
await manager.dispose("cli_interactive_startup_cancelled");
|
||||
throw new Error("interactive runtime shutdown requested");
|
||||
}
|
||||
sessionManager = manager;
|
||||
runtimeHooks = createRuntimeHooks({
|
||||
verbose: config.verbose,
|
||||
yolo: config.mode === "yolo",
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
dispatchHookEvent: async (payload) => {
|
||||
await manager.handleHookEvent(payload);
|
||||
},
|
||||
});
|
||||
const onAgentEvent = (event: AgentEvent): void => {
|
||||
uiEvents.emit("agent", event);
|
||||
};
|
||||
unsubscribeAgent = subscribeToAgentEvents(manager, onAgentEvent);
|
||||
unsubscribePendingPrompts = subscribeToPendingPromptEvents(manager, {
|
||||
onPendingPrompts: (event: PendingPromptSnapshot): void => {
|
||||
uiEvents.emit("pending-prompts", event);
|
||||
},
|
||||
onPendingPromptSubmitted: (event: PendingPromptSubmittedEvent): void => {
|
||||
uiEvents.emit("pending-prompt-submitted", event);
|
||||
},
|
||||
});
|
||||
return manager;
|
||||
};
|
||||
/**
|
||||
* Starts a brand-new interactive session. This path is used for normal boot
|
||||
* when we are not resuming, and for later reset/new-session flows where we
|
||||
* intentionally want a fresh session id.
|
||||
*/
|
||||
const startFreshSession = async (initial: typeof initialMessages = []) => {
|
||||
const started = await sessionManager.start({
|
||||
const startFreshSession = async (
|
||||
initial: Awaited<ReturnType<typeof loadInteractiveResumeMessages>> = [],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
) => {
|
||||
const manager = await ensureSessionManager();
|
||||
if (!runtimeHooks) {
|
||||
throw new Error("interactive runtime hooks are unavailable");
|
||||
}
|
||||
const started = await manager.start({
|
||||
source: SessionSource.CLI,
|
||||
config: {
|
||||
...config,
|
||||
@@ -270,6 +285,7 @@ export async function runInteractive(
|
||||
},
|
||||
interactive: true,
|
||||
initialMessages: initial,
|
||||
...(sessionMetadata ? { sessionMetadata } : {}),
|
||||
localRuntime: {
|
||||
userInstructionWatcher,
|
||||
onTeamRestored: () => {},
|
||||
@@ -277,6 +293,62 @@ export async function runInteractive(
|
||||
});
|
||||
applyStartedSession(started);
|
||||
};
|
||||
|
||||
/**
|
||||
* Forks the current active session by copying its full message history and
|
||||
* checkpoint metadata into a brand-new session. The new session's persisted
|
||||
* metadata records the origin session id, source, and any checkpoint history
|
||||
* so the lineage can be traced back.
|
||||
*/
|
||||
const forkCurrentSession = async (): Promise<
|
||||
ForkSessionResult | undefined
|
||||
> => {
|
||||
const manager = sessionManager;
|
||||
if (!manager || !activeSessionId) {
|
||||
return undefined;
|
||||
}
|
||||
const forkedFromSessionId = activeSessionId;
|
||||
// Read the current session record so we can copy checkpoint metadata.
|
||||
const sessionRecord = await manager.get(forkedFromSessionId);
|
||||
// Read the full message history from the source session.
|
||||
const messages = await manager
|
||||
.readMessages(forkedFromSessionId)
|
||||
.catch(() => undefined);
|
||||
if (!messages) {
|
||||
return undefined;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
throw new Error("Cannot fork an empty session.");
|
||||
}
|
||||
// Stop the current session before starting the fork so the two sessions
|
||||
// do not share the same in-process state.
|
||||
await manager.stop(forkedFromSessionId);
|
||||
// Build fork lineage metadata. We copy any existing checkpoint metadata
|
||||
// from the original session so the forked session knows what checkpoints
|
||||
// were present at the time of the fork.
|
||||
const checkpointMetadata = sessionRecord?.metadata?.checkpoint ?? undefined;
|
||||
const forkMetadata: Record<string, unknown> = {
|
||||
fork: {
|
||||
forkedFromSessionId,
|
||||
forkedAt: new Date().toISOString(),
|
||||
source: sessionRecord?.source ?? SessionSource.CLI,
|
||||
...(checkpointMetadata !== undefined
|
||||
? { checkpoints: checkpointMetadata }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
// Preserve any other existing metadata fields from the original session
|
||||
// so nothing is lost (e.g. title, totalCost).
|
||||
if (sessionRecord?.metadata) {
|
||||
for (const [key, value] of Object.entries(sessionRecord.metadata)) {
|
||||
if (key !== "fork") {
|
||||
forkMetadata[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
await startFreshSession(messages, forkMetadata);
|
||||
return { forkedFromSessionId, newSessionId: activeSessionId };
|
||||
};
|
||||
/**
|
||||
* Starts the initial interactive session by continuing an existing historical
|
||||
* session id. This is only used once during startup when `--resume` selected
|
||||
@@ -284,9 +356,13 @@ export async function runInteractive(
|
||||
*/
|
||||
const startResumedSession = async (
|
||||
resumeId: string,
|
||||
initial: typeof initialMessages,
|
||||
initial: Awaited<ReturnType<typeof loadInteractiveResumeMessages>>,
|
||||
) => {
|
||||
const started = await sessionManager.start({
|
||||
const manager = await ensureSessionManager();
|
||||
if (!runtimeHooks) {
|
||||
throw new Error("interactive runtime hooks are unavailable");
|
||||
}
|
||||
const started = await manager.start({
|
||||
source: SessionSource.CLI,
|
||||
config: {
|
||||
...config,
|
||||
@@ -315,16 +391,52 @@ export async function runInteractive(
|
||||
});
|
||||
applyStartedSession(started);
|
||||
};
|
||||
if (initialResumeSessionId) {
|
||||
await startResumedSession(initialResumeSessionId, initialMessages);
|
||||
} else {
|
||||
await startFreshSession(initialMessages);
|
||||
}
|
||||
const ensureInteractiveRuntimeReady = async (): Promise<void> => {
|
||||
if (startupPromise) {
|
||||
return await startupPromise;
|
||||
}
|
||||
startupPromise = (async () => {
|
||||
const manager = await ensureSessionManager();
|
||||
const initialMessages = await loadInteractiveResumeMessages(
|
||||
manager,
|
||||
resumeSessionId,
|
||||
);
|
||||
if (resumeSessionId?.trim()) {
|
||||
const previewMessages = getLastSessionPreviewMessages(
|
||||
initialMessages ?? [],
|
||||
2,
|
||||
);
|
||||
if (previewMessages.length > 0) {
|
||||
writeln(
|
||||
`${c.dim}Resuming ${resumeSessionId.trim()} with recent context:${c.reset}`,
|
||||
);
|
||||
for (const previewMessage of previewMessages) {
|
||||
writeln(
|
||||
`${c.dim}${formatPreviewMessageText(previewMessage)}${c.reset}`,
|
||||
);
|
||||
}
|
||||
writeln();
|
||||
}
|
||||
}
|
||||
if (shutdownRequested) {
|
||||
return;
|
||||
}
|
||||
if (initialResumeSessionId) {
|
||||
await startResumedSession(initialResumeSessionId, initialMessages);
|
||||
} else {
|
||||
await startFreshSession(initialMessages);
|
||||
}
|
||||
})().catch((error) => {
|
||||
startupError = error;
|
||||
throw error;
|
||||
});
|
||||
return await startupPromise;
|
||||
};
|
||||
|
||||
let isRunning = false;
|
||||
let abortRequested = false;
|
||||
const abortAll = () => {
|
||||
if (abortRequested) {
|
||||
if (abortRequested || !sessionManager || !activeSessionId) {
|
||||
return false;
|
||||
}
|
||||
abortRequested = true;
|
||||
@@ -371,18 +483,27 @@ export async function runInteractive(
|
||||
return await cleanupPromise;
|
||||
}
|
||||
cleanupPromise = (async () => {
|
||||
shutdownRequested = true;
|
||||
requestExit();
|
||||
process.off("SIGINT", handleSigint);
|
||||
process.off("SIGTERM", handleSigterm);
|
||||
unsubscribeAgent();
|
||||
unsubscribePendingPrompts();
|
||||
try {
|
||||
await sessionManager.stop(activeSessionId);
|
||||
await startupPromise?.catch(() => {});
|
||||
} finally {
|
||||
unsubscribeAgent();
|
||||
unsubscribePendingPrompts();
|
||||
}
|
||||
try {
|
||||
if (sessionManager && activeSessionId) {
|
||||
await sessionManager.stop(activeSessionId);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await sessionManager.dispose("cli_interactive_shutdown");
|
||||
if (sessionManager) {
|
||||
await sessionManager.dispose("cli_interactive_shutdown");
|
||||
}
|
||||
} finally {
|
||||
await runtimeHooks.shutdown();
|
||||
await runtimeHooks?.shutdown();
|
||||
}
|
||||
}
|
||||
setActiveRuntimeAbort(undefined);
|
||||
@@ -397,8 +518,8 @@ export async function runInteractive(
|
||||
React.createElement(InteractiveTui, {
|
||||
config,
|
||||
initialView: options?.initialView ?? "chat",
|
||||
initialRepoStatus,
|
||||
workflowSlashCommands,
|
||||
loadAdditionalSlashCommands,
|
||||
loadWelcomeLine: async () =>
|
||||
await resolveClineWelcomeLine({
|
||||
config,
|
||||
@@ -410,7 +531,35 @@ export async function runInteractive(
|
||||
watcher: userInstructionWatcher,
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
|
||||
availabilityContext: {
|
||||
mode: config.mode,
|
||||
modelId: config.modelId,
|
||||
providerId: config.providerId,
|
||||
enableSpawnAgent: config.enableSpawnAgent,
|
||||
enableAgentTeams: config.enableAgentTeams,
|
||||
},
|
||||
}),
|
||||
onToggleConfigItem: async (item) => {
|
||||
if (
|
||||
item.source !== "workspace-plugin" &&
|
||||
item.source !== "global-plugin"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
toggleDisabledTool(item.name);
|
||||
return await loadInteractiveConfigData({
|
||||
watcher: userInstructionWatcher,
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
|
||||
availabilityContext: {
|
||||
mode: config.mode,
|
||||
modelId: config.modelId,
|
||||
providerId: config.providerId,
|
||||
enableSpawnAgent: config.enableSpawnAgent,
|
||||
enableAgentTeams: config.enableAgentTeams,
|
||||
},
|
||||
});
|
||||
},
|
||||
subscribeToEvents: ({
|
||||
onAgentEvent: onAgent,
|
||||
onTeamEvent: onTeam,
|
||||
@@ -429,6 +578,7 @@ export async function runInteractive(
|
||||
};
|
||||
},
|
||||
onSubmit: async (input, _mode, delivery) => {
|
||||
await ensureInteractiveRuntimeReady();
|
||||
abortRequested = false;
|
||||
if (!delivery) {
|
||||
isRunning = true;
|
||||
@@ -449,7 +599,7 @@ export async function runInteractive(
|
||||
if (!config.enableAgentTeams) {
|
||||
await enableTeamsForPrompt(config);
|
||||
// Restart the session with teams enabled.
|
||||
if (activeSessionId) {
|
||||
if (sessionManager && activeSessionId) {
|
||||
await sessionManager.stop(activeSessionId);
|
||||
}
|
||||
await startFreshSession([]);
|
||||
@@ -461,7 +611,7 @@ export async function runInteractive(
|
||||
if (
|
||||
await maybeHandleChatCommand(input, {
|
||||
enabled: enableChatCommands,
|
||||
host: chatCommandHost,
|
||||
host: interactiveChatCommandHost,
|
||||
getState: () => ({
|
||||
...chatCommandState,
|
||||
autoApproveTools: autoApproveAllRef.current,
|
||||
@@ -477,7 +627,7 @@ export async function runInteractive(
|
||||
commandOutput = text;
|
||||
},
|
||||
reset: async () => {
|
||||
if (activeSessionId) {
|
||||
if (sessionManager && activeSessionId) {
|
||||
await sessionManager.stop(activeSessionId);
|
||||
}
|
||||
await startFreshSession([]);
|
||||
@@ -493,6 +643,7 @@ export async function runInteractive(
|
||||
`cwd=${chatCommandState.cwd}`,
|
||||
`workspaceRoot=${chatCommandState.workspaceRoot}`,
|
||||
].join("\n"),
|
||||
fork: forkCurrentSession,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
@@ -504,13 +655,21 @@ export async function runInteractive(
|
||||
commandOutput,
|
||||
};
|
||||
}
|
||||
const userInput = await buildUserInputMessage(
|
||||
input,
|
||||
userInstructionWatcher,
|
||||
);
|
||||
const {
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(input, userInstructionWatcher);
|
||||
if (!sessionManager) {
|
||||
throw startupError instanceof Error
|
||||
? startupError
|
||||
: new Error("interactive session manager is unavailable");
|
||||
}
|
||||
const result = await sessionManager.send({
|
||||
sessionId: activeSessionId,
|
||||
prompt: userInput,
|
||||
userImages: userImages.length > 0 ? userImages : undefined,
|
||||
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
||||
delivery,
|
||||
});
|
||||
if (!result) {
|
||||
@@ -552,6 +711,13 @@ export async function runInteractive(
|
||||
}),
|
||||
{ exitOnCtrlC: false },
|
||||
);
|
||||
void ensureInteractiveRuntimeReady().catch((error) => {
|
||||
if (shutdownRequested) {
|
||||
return;
|
||||
}
|
||||
writeErr(error instanceof Error ? error.message : String(error));
|
||||
requestExit();
|
||||
});
|
||||
unmountInteractiveUi = () => {
|
||||
try {
|
||||
inkApp.unmount();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
type BuiltinToolAvailabilityContext,
|
||||
getCoreBuiltinToolCatalog,
|
||||
resolveDisabledToolNames,
|
||||
type ToolCatalogEntry,
|
||||
} from "@clinebot/core";
|
||||
|
||||
export type { ToolCatalogEntry } from "@clinebot/core";
|
||||
|
||||
export function getToolCatalog(
|
||||
availabilityContext?: BuiltinToolAvailabilityContext,
|
||||
): ToolCatalogEntry[] {
|
||||
return getCoreBuiltinToolCatalog({
|
||||
disabledToolIds: resolveDisabledToolNames(),
|
||||
...availabilityContext,
|
||||
});
|
||||
}
|
||||
@@ -76,18 +76,22 @@ describe("createCliCore", () => {
|
||||
await sessionModule.createCliCore();
|
||||
|
||||
expect(createCore).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
backendMode: "local",
|
||||
expect.objectContaining({
|
||||
backendMode: "hub",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets core interpret env-managed backend routing by default", async () => {
|
||||
it("prefers the shared hub backend by default", async () => {
|
||||
await sessionModule.createCliCore();
|
||||
|
||||
expect(createCore).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
backendMode: expect.anything(),
|
||||
expect.objectContaining({
|
||||
backendMode: "hub",
|
||||
hub: expect.objectContaining({
|
||||
clientType: "cli",
|
||||
displayName: "Cline CLI",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -102,6 +106,24 @@ describe("createCliCore", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the shared hub backend when custom tool executors are provided", async () => {
|
||||
await sessionModule.createCliCore({
|
||||
defaultToolExecutors: {
|
||||
submit: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(createCore).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backendMode: "hub",
|
||||
hub: expect.objectContaining({
|
||||
clientType: "cli",
|
||||
displayName: "Cline CLI",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes env-managed routing through to core when local is requested via env", async () => {
|
||||
process.env.CLINE_SESSION_BACKEND_MODE = "local";
|
||||
|
||||
@@ -138,7 +160,7 @@ describe("createCliCore", () => {
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
"CLI core runtime routing selected",
|
||||
{
|
||||
backendMode: "env-managed",
|
||||
backendMode: "hub",
|
||||
rpcAddress: "127.0.0.1:4317",
|
||||
forceLocalBackend: false,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ToolApprovalResult,
|
||||
} from "@clinebot/core";
|
||||
import { ClineCore } from "@clinebot/core";
|
||||
import { resolveWorkspaceRoot } from "../utils/helpers";
|
||||
import { getCliTelemetryService } from "../utils/telemetry";
|
||||
|
||||
function toSessionRecordLike(
|
||||
@@ -21,13 +22,33 @@ export async function createCliCore(options?: {
|
||||
toolPolicies?: AgentConfig["toolPolicies"];
|
||||
logger?: BasicLogger;
|
||||
forceLocalBackend?: boolean;
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
requestToolApproval?: (
|
||||
request: ToolApprovalRequest,
|
||||
) => Promise<ToolApprovalResult>;
|
||||
}): Promise<ClineCore> {
|
||||
const explicitBackendMode = options?.forceLocalBackend ? "local" : undefined;
|
||||
const explicitBackendMode = options?.forceLocalBackend
|
||||
? "local"
|
||||
: process.env.CLINE_SESSION_BACKEND_MODE?.trim().toLowerCase() ===
|
||||
"local" || process.env.CLINE_VCR?.trim()
|
||||
? undefined
|
||||
: "hub";
|
||||
const cwd = options?.cwd?.trim() || process.cwd();
|
||||
const workspaceRoot =
|
||||
options?.workspaceRoot?.trim() || resolveWorkspaceRoot(cwd);
|
||||
const core = await ClineCore.create({
|
||||
...(explicitBackendMode ? { backendMode: explicitBackendMode } : {}),
|
||||
...(explicitBackendMode === "hub"
|
||||
? {
|
||||
hub: {
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
clientType: "cli",
|
||||
displayName: "Cline CLI",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
defaultToolExecutors: options?.defaultToolExecutors,
|
||||
telemetry: getCliTelemetryService(options?.logger),
|
||||
logger: options?.logger,
|
||||
@@ -44,11 +65,18 @@ export async function createCliCore(options?: {
|
||||
|
||||
async function withCliCore<T>(
|
||||
run: (core: ClineCore) => Promise<T>,
|
||||
options?: { forceLocalBackend?: boolean; logger?: BasicLogger },
|
||||
options?: {
|
||||
forceLocalBackend?: boolean;
|
||||
logger?: BasicLogger;
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
},
|
||||
): Promise<T> {
|
||||
const core = await createCliCore({
|
||||
forceLocalBackend: options?.forceLocalBackend,
|
||||
logger: options?.logger,
|
||||
cwd: options?.cwd,
|
||||
workspaceRoot: options?.workspaceRoot,
|
||||
});
|
||||
try {
|
||||
return await run(core);
|
||||
@@ -59,16 +87,28 @@ async function withCliCore<T>(
|
||||
|
||||
export async function listSessions(
|
||||
limit = 200,
|
||||
options?: { workspaceRoot?: string },
|
||||
): Promise<SessionHistoryRecord[]> {
|
||||
return await withCliCore(async (core) => await core.list(limit));
|
||||
const rows = await withCliCore(async (core) => await core.list(limit), {
|
||||
forceLocalBackend: true,
|
||||
cwd: options?.workspaceRoot,
|
||||
workspaceRoot: options?.workspaceRoot,
|
||||
});
|
||||
if (!options?.workspaceRoot) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) => row.workspaceRoot === options.workspaceRoot);
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
sessionId: string,
|
||||
): Promise<{ deleted: boolean }> {
|
||||
return await withCliCore(async (core) => ({
|
||||
deleted: await core.delete(sessionId),
|
||||
}));
|
||||
return await withCliCore(
|
||||
async (core) => ({
|
||||
deleted: await core.delete(sessionId),
|
||||
}),
|
||||
{ forceLocalBackend: true },
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
@@ -81,6 +121,7 @@ export async function updateSession(
|
||||
): Promise<{ updated: boolean }> {
|
||||
return await withCliCore(
|
||||
async (core) => await core.update(sessionId, updates),
|
||||
{ forceLocalBackend: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,22 +132,29 @@ export async function getSessionRow(
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
return await withCliCore(async (core) =>
|
||||
toSessionRecordLike(await core.get(target)),
|
||||
return await withCliCore(
|
||||
async (core) => toSessionRecordLike(await core.get(target)),
|
||||
{ forceLocalBackend: true },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLatestSessionRow(): Promise<unknown | undefined> {
|
||||
return await withCliCore(async (core) => {
|
||||
const rows = await core.list(1);
|
||||
return toSessionRecordLike(rows[0]);
|
||||
});
|
||||
return await withCliCore(
|
||||
async (core) => {
|
||||
const rows = await core.list(1);
|
||||
return toSessionRecordLike(rows[0]);
|
||||
},
|
||||
{ forceLocalBackend: true },
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleSessionHookEvent(
|
||||
payload: HookEventPayload,
|
||||
): Promise<void> {
|
||||
await withCliCore(async (core) => {
|
||||
await core.handleHookEvent(payload);
|
||||
});
|
||||
await withCliCore(
|
||||
async (core) => {
|
||||
await core.handleHookEvent(payload);
|
||||
},
|
||||
{ forceLocalBackend: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,16 +8,20 @@ import type {
|
||||
|
||||
export const CONFIG_TABS: InteractiveConfigTab[] = [
|
||||
"tools",
|
||||
"plugins",
|
||||
"rules",
|
||||
"skills",
|
||||
"agents",
|
||||
"hooks",
|
||||
"skills",
|
||||
"rules",
|
||||
"plugins",
|
||||
"mcp",
|
||||
];
|
||||
|
||||
const MAX_CONFIG_ITEMS_VISIBLE = 12;
|
||||
const MAX_MENU_ITEMS_VISIBLE = 5;
|
||||
|
||||
type ConfigSection = {
|
||||
title: string;
|
||||
items: InteractiveConfigItem[];
|
||||
};
|
||||
|
||||
export function toTabLabel(tab: InteractiveConfigTab): string {
|
||||
switch (tab) {
|
||||
@@ -35,13 +39,23 @@ export function toTabLabel(tab: InteractiveConfigTab): string {
|
||||
return "Plugins";
|
||||
case "mcp":
|
||||
return "MCP";
|
||||
default:
|
||||
return tab;
|
||||
}
|
||||
}
|
||||
|
||||
function truncatePath(path: string, maxLength = 70): string {
|
||||
function formatSeparator(char = "─", width?: number): string {
|
||||
const columns = width ?? process.stdout.columns ?? 80;
|
||||
return char.repeat(Math.max(10, columns));
|
||||
}
|
||||
|
||||
function truncatePath(path: string, maxLength = 72, tail = false): string {
|
||||
if (path.length <= maxLength) {
|
||||
return path;
|
||||
}
|
||||
if (tail) {
|
||||
return `${path.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
return `...${path.slice(-(maxLength - 3))}`;
|
||||
}
|
||||
|
||||
@@ -53,7 +67,7 @@ export interface VisibleWindow<T> {
|
||||
export function getVisibleWindow<T>(
|
||||
items: T[],
|
||||
selectedIndex: number,
|
||||
maxVisible = MAX_MENU_ITEMS_VISIBLE,
|
||||
maxVisible = MAX_CONFIG_ITEMS_VISIBLE,
|
||||
): VisibleWindow<T> {
|
||||
if (items.length <= maxVisible) {
|
||||
return { items, startIndex: 0 };
|
||||
@@ -67,33 +81,193 @@ export function getVisibleWindow<T>(
|
||||
return { items: items.slice(startIndex, endIndex), startIndex };
|
||||
}
|
||||
|
||||
function sortBySourceThenName(
|
||||
items: InteractiveConfigItem[],
|
||||
): InteractiveConfigItem[] {
|
||||
const sourceRank = (source: InteractiveConfigItem["source"]): number => {
|
||||
switch (source) {
|
||||
case "builtin":
|
||||
return 0;
|
||||
case "workspace":
|
||||
return 1;
|
||||
case "workspace-plugin":
|
||||
return 2;
|
||||
case "global":
|
||||
return 3;
|
||||
case "global-plugin":
|
||||
return 4;
|
||||
default:
|
||||
return 5;
|
||||
}
|
||||
};
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
if (a.source !== b.source) {
|
||||
return sourceRank(a.source) - sourceRank(b.source);
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveActiveConfigItems(
|
||||
configData: InteractiveConfigData,
|
||||
configTab: InteractiveConfigTab,
|
||||
): InteractiveConfigItem[] {
|
||||
switch (configTab) {
|
||||
case "skills":
|
||||
return [...configData.workflows, ...configData.skills].sort((a, b) => {
|
||||
if (a.source !== b.source) {
|
||||
return a.source === "workspace" ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return sortBySourceThenName([
|
||||
...configData.skills.map((item) => ({
|
||||
...item,
|
||||
description: item.description,
|
||||
})),
|
||||
...configData.workflows.map((item) => ({
|
||||
...item,
|
||||
description: item.description,
|
||||
})),
|
||||
]);
|
||||
case "rules":
|
||||
return configData.rules;
|
||||
return sortBySourceThenName(configData.rules);
|
||||
case "hooks":
|
||||
return configData.hooks;
|
||||
return sortBySourceThenName(configData.hooks);
|
||||
case "agents":
|
||||
return configData.agents;
|
||||
return sortBySourceThenName(configData.agents);
|
||||
case "plugins":
|
||||
return configData.plugins;
|
||||
return sortBySourceThenName(configData.plugins);
|
||||
case "mcp":
|
||||
return configData.mcp;
|
||||
return sortBySourceThenName(configData.mcp);
|
||||
case "tools":
|
||||
return configData.tools;
|
||||
return sortBySourceThenName(configData.tools);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildSections(
|
||||
configTab: InteractiveConfigTab,
|
||||
items: InteractiveConfigItem[],
|
||||
): ConfigSection[] {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (configTab === "hooks") {
|
||||
const groups = new Map<string, ConfigSection>();
|
||||
for (const item of items) {
|
||||
const title =
|
||||
item.source === "workspace"
|
||||
? "Workspace Hooks:"
|
||||
: item.source === "global"
|
||||
? "Global Hooks:"
|
||||
: `${item.source} Hooks:`;
|
||||
const section = groups.get(title) ?? { title, items: [] };
|
||||
section.items.push(item);
|
||||
groups.set(title, section);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
if (configTab === "tools") {
|
||||
const groups = new Map<string, ConfigSection>();
|
||||
for (const item of items) {
|
||||
const title =
|
||||
item.source === "builtin"
|
||||
? "Builtin Tools:"
|
||||
: item.source === "workspace-plugin"
|
||||
? "Workspace Plugin Tools:"
|
||||
: item.source === "global-plugin"
|
||||
? "Global Plugin Tools:"
|
||||
: "Tools:";
|
||||
const section = groups.get(title) ?? { title, items: [] };
|
||||
section.items.push(item);
|
||||
groups.set(title, section);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
if (configTab === "skills") {
|
||||
const groups = new Map<string, ConfigSection>();
|
||||
for (const item of items) {
|
||||
const isWorkflow =
|
||||
item.id === item.name.toLowerCase() || item.name.startsWith("/");
|
||||
const kindLabel = isWorkflow ? "Workflow" : "Skill";
|
||||
let sourceLabel: string;
|
||||
switch (item.source) {
|
||||
case "workspace":
|
||||
sourceLabel = "Workspace";
|
||||
break;
|
||||
case "global":
|
||||
sourceLabel = "Global";
|
||||
break;
|
||||
default:
|
||||
sourceLabel =
|
||||
item.source.charAt(0).toUpperCase() + item.source.slice(1);
|
||||
break;
|
||||
}
|
||||
const title = `${sourceLabel} ${kindLabel}s:`;
|
||||
const section = groups.get(title) ?? { title, items: [] };
|
||||
section.items.push(item);
|
||||
groups.set(title, section);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
const groups = new Map<string, ConfigSection>();
|
||||
for (const item of items) {
|
||||
let sectionLabel: string;
|
||||
switch (item.source) {
|
||||
case "workspace":
|
||||
sectionLabel = "Workspace";
|
||||
break;
|
||||
case "global":
|
||||
sectionLabel = "Global";
|
||||
break;
|
||||
default:
|
||||
sectionLabel =
|
||||
item.source.charAt(0).toUpperCase() + item.source.slice(1);
|
||||
break;
|
||||
}
|
||||
const title = `${sectionLabel} ${toTabLabel(configTab)}:`;
|
||||
const section = groups.get(title) ?? { title, items: [] };
|
||||
section.items.push(item);
|
||||
groups.set(title, section);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function getStatusColor(item: InteractiveConfigItem): string | undefined {
|
||||
if (typeof item.enabled !== "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
return item.enabled ? "green" : "red";
|
||||
}
|
||||
|
||||
function renderConfigRow(
|
||||
item: InteractiveConfigItem,
|
||||
isSelected: boolean,
|
||||
indexKey: string,
|
||||
): React.ReactElement {
|
||||
const statusColor = getStatusColor(item);
|
||||
const statusSymbol =
|
||||
typeof item.enabled === "boolean" ? (item.enabled ? "●" : "○") : "•";
|
||||
const title = [item.name, item.source].filter(Boolean).join(" · ");
|
||||
const detail = truncatePath(item.path, 56);
|
||||
|
||||
return React.createElement(
|
||||
Box,
|
||||
{ flexDirection: "column", key: indexKey },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: isSelected ? "cyan" : undefined },
|
||||
`${isSelected ? "❯ " : " "}${statusSymbol} ${title}`,
|
||||
),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: statusColor ?? "gray" },
|
||||
` ${detail}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConfigViewProps {
|
||||
configTab: InteractiveConfigTab;
|
||||
configSelectedIndex: number;
|
||||
@@ -118,70 +292,98 @@ export function ConfigView(props: ConfigViewProps): React.ReactElement | null {
|
||||
[activeConfigItems, configSelectedIndex],
|
||||
);
|
||||
|
||||
const renderConfigItems = isLoadingConfig
|
||||
? React.createElement(Text, { color: "gray" }, "Loading config...")
|
||||
: activeConfigItems.length === 0
|
||||
? React.createElement(
|
||||
Text,
|
||||
{ color: "gray" },
|
||||
`No ${toTabLabel(configTab).toLowerCase()} found.`,
|
||||
)
|
||||
: visibleConfigItems.items.map((item, index) => {
|
||||
const absoluteIndex = visibleConfigItems.startIndex + index;
|
||||
const selected = absoluteIndex === configSelectedIndex;
|
||||
const prefix = selected ? "❯" : " ";
|
||||
const enabledTag =
|
||||
typeof item.enabled === "boolean"
|
||||
? item.enabled
|
||||
? "enabled"
|
||||
: "disabled"
|
||||
: "";
|
||||
const details = [item.source, enabledTag, truncatePath(item.path, 42)]
|
||||
.filter((value) => value.length > 0)
|
||||
.join(" · ");
|
||||
return React.createElement(
|
||||
Box,
|
||||
{
|
||||
flexDirection: "column",
|
||||
key: `${item.id}:${absoluteIndex}`,
|
||||
},
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: selected ? "blue" : undefined },
|
||||
`${prefix} ${item.name}`,
|
||||
),
|
||||
React.createElement(Text, { color: "gray" }, ` ${details}`),
|
||||
);
|
||||
});
|
||||
const visibleSections = useMemo(
|
||||
() => buildSections(configTab, visibleConfigItems.items),
|
||||
[configTab, visibleConfigItems.items],
|
||||
);
|
||||
const separator = formatSeparator();
|
||||
const listStart = visibleConfigItems.startIndex;
|
||||
const listEnd = listStart + visibleConfigItems.items.length;
|
||||
|
||||
return React.createElement(
|
||||
Box,
|
||||
{
|
||||
flexDirection: "column",
|
||||
borderStyle: "round",
|
||||
paddingX: 1,
|
||||
marginBottom: 1,
|
||||
},
|
||||
React.createElement(Text, { color: "cyan" }, "Configuration"),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ bold: true, color: "white" },
|
||||
"⚙ Cline Configuration",
|
||||
),
|
||||
React.createElement(Text, { color: "gray" }, separator),
|
||||
React.createElement(
|
||||
Box,
|
||||
{ marginBottom: 1, gap: 1 },
|
||||
CONFIG_TABS.map((tab) =>
|
||||
{ marginBottom: 1 },
|
||||
...CONFIG_TABS.flatMap((tab, index) => [
|
||||
index > 0
|
||||
? React.createElement(
|
||||
Text,
|
||||
{ color: "gray", key: `${tab}:sep` },
|
||||
" │ ",
|
||||
)
|
||||
: null,
|
||||
React.createElement(
|
||||
Text,
|
||||
{
|
||||
key: tab,
|
||||
color: tab === configTab ? "blue" : "gray",
|
||||
bold: tab === configTab,
|
||||
color: tab === configTab ? "cyan" : "gray",
|
||||
key: tab,
|
||||
},
|
||||
tab === configTab ? `[${toTabLabel(tab)}]` : toTabLabel(tab),
|
||||
),
|
||||
]),
|
||||
),
|
||||
React.createElement(Text, { color: "gray" }, separator),
|
||||
isLoadingConfig
|
||||
? React.createElement(Text, { color: "gray" }, "Loading config...")
|
||||
: activeConfigItems.length === 0
|
||||
? React.createElement(
|
||||
Text,
|
||||
{ color: "gray" },
|
||||
`No ${toTabLabel(configTab).toLowerCase()} found.`,
|
||||
)
|
||||
: React.createElement(
|
||||
Box,
|
||||
{ flexDirection: "column" },
|
||||
...visibleSections.flatMap((section) => [
|
||||
React.createElement(
|
||||
Box,
|
||||
{ key: `${section.title}:header`, marginTop: 1 },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ bold: true, color: "yellow" },
|
||||
section.title,
|
||||
),
|
||||
),
|
||||
...section.items.map((item) =>
|
||||
renderConfigRow(
|
||||
item,
|
||||
activeConfigItems[configSelectedIndex]?.id === item.id,
|
||||
`${section.title}:${item.id}`,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
activeConfigItems.length > MAX_CONFIG_ITEMS_VISIBLE
|
||||
? React.createElement(
|
||||
Box,
|
||||
{ marginTop: 1 },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: "gray" },
|
||||
`${listStart > 0 ? "↑ " : " "}Showing ${listStart + 1}-${listEnd} of ${activeConfigItems.length}${listEnd < activeConfigItems.length ? " ↓" : " "}`,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
React.createElement(Text, { color: "gray" }, separator),
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: "column" },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: "gray" },
|
||||
"↑/↓ or j/k Navigate • ←/→ tabs • 1-8 tabs • Enter Toggle • Esc Exit",
|
||||
),
|
||||
),
|
||||
renderConfigItems,
|
||||
activeConfigItems.length >
|
||||
visibleConfigItems.startIndex + visibleConfigItems.items.length
|
||||
? React.createElement(Text, { color: "gray" }, " ▼")
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,19 +19,6 @@ function formatHistoryTitle(
|
||||
return truncateStr(normalized.replace(/\s+/g, " "), 40);
|
||||
}
|
||||
|
||||
function formatCheckpointSummary(row: SessionHistoryRecord): string {
|
||||
const checkpoint = row.metadata?.checkpoint;
|
||||
const count = checkpoint?.history?.length ?? 0;
|
||||
const latestRun = checkpoint?.latest?.runCount;
|
||||
if (count <= 0) {
|
||||
return "";
|
||||
}
|
||||
if (typeof latestRun === "number" && Number.isFinite(latestRun)) {
|
||||
return ` - checkpoints:${count} latest-run:${latestRun}`;
|
||||
}
|
||||
return ` - checkpoints:${count}`;
|
||||
}
|
||||
|
||||
export function formatCheckpointBadge(
|
||||
row: SessionHistoryRecord,
|
||||
): string | undefined {
|
||||
@@ -42,9 +29,9 @@ export function formatCheckpointBadge(
|
||||
return undefined;
|
||||
}
|
||||
if (typeof latestRun === "number" && Number.isFinite(latestRun)) {
|
||||
return `CP ${count} R${latestRun}`;
|
||||
return `CP${count}R${latestRun}`;
|
||||
}
|
||||
return `CP ${count}`;
|
||||
return `CP${count}`;
|
||||
}
|
||||
|
||||
export function formatCheckpointDetail(
|
||||
@@ -72,13 +59,25 @@ export function formatHistoryListLine(row: SessionHistoryRecord): string {
|
||||
const title = formatHistoryTitle(row.metadata?.title, row.prompt);
|
||||
if (!title) return "";
|
||||
const cost = formatUsd(row.metadata?.totalCost ?? 0, 2);
|
||||
const provider = truncateStr(
|
||||
row.provider?.trim() || "(unknown-provider)",
|
||||
20,
|
||||
);
|
||||
const model = truncateStr(row.model?.trim() || "(unknown-model)", 28);
|
||||
const date = formatHumanReadableDate(row.startedAt);
|
||||
return `${date} - ${cost} - ${provider}:${model} - ${title}${formatCheckpointSummary(row)} `;
|
||||
const provider = truncateStr(row.provider?.trim() || "unknown", 20);
|
||||
const model = truncateStr(row.model?.trim() || "", 28);
|
||||
|
||||
const checkpointCreatedAt = row.metadata?.checkpoint?.latest?.createdAt;
|
||||
const timestamp =
|
||||
typeof checkpointCreatedAt === "number" &&
|
||||
Number.isFinite(checkpointCreatedAt)
|
||||
? checkpointCreatedAt
|
||||
: new Date(row.startedAt).getTime();
|
||||
const date = new Date(timestamp);
|
||||
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||
const year = date.getUTCFullYear();
|
||||
const hour = String(date.getUTCHours()).padStart(2, "0");
|
||||
const minute = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
const dateStr = `${month}/${day}/${year}`;
|
||||
const timeStr = `${hour}:${minute}`;
|
||||
return `${dateStr} ${timeStr} ${provider}:${model} | ${cost} | ${title}`;
|
||||
}
|
||||
|
||||
export interface HistoryListViewProps {
|
||||
|
||||
@@ -123,7 +123,7 @@ export function StatusBar({
|
||||
Text,
|
||||
{ color: "gray" },
|
||||
isConfigViewOpen
|
||||
? "Config mode: Tab tabs \u00b7 \u2191/\u2193 navigate \u00b7 Esc close"
|
||||
? "Config mode: \u2190/\u2192 or 1-8 tabs \u00b7 \u2191/\u2193 or j/k navigate \u00b7 Esc close"
|
||||
: undefined,
|
||||
),
|
||||
isConfigViewOpen
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { loadInteractiveConfigData } from "./interactive-config";
|
||||
|
||||
describe("interactive config agent listing", () => {
|
||||
const envSnapshot = {
|
||||
CLINE_DIR: process.env.CLINE_DIR,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CLINE_DIR = envSnapshot.CLINE_DIR;
|
||||
});
|
||||
|
||||
it("lists configured agents even when their tool names are unsupported", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-interactive-config-"));
|
||||
const workspaceRoot = join(tempRoot, "workspace");
|
||||
const globalAgentsDir = join(tempRoot, ".cline", "agents");
|
||||
const workspaceAgentsDir = join(workspaceRoot, ".cline", "agents");
|
||||
|
||||
await mkdir(globalAgentsDir, { recursive: true });
|
||||
await mkdir(workspaceAgentsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
process.env.CLINE_DIR = join(tempRoot, ".cline");
|
||||
|
||||
await writeFile(
|
||||
join(globalAgentsDir, "subagent.yml"),
|
||||
`---
|
||||
name: subagent
|
||||
description: legacy global config
|
||||
tools: execute_command, write_to_file
|
||||
---
|
||||
Legacy global agent.`,
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(workspaceAgentsDir, "reviewer.yml"),
|
||||
`---
|
||||
name: reviewer
|
||||
description: legacy workspace config
|
||||
tools: execute_command, read_file
|
||||
---
|
||||
Legacy workspace agent.`,
|
||||
);
|
||||
|
||||
const config = await loadInteractiveConfigData({
|
||||
cwd: workspaceRoot,
|
||||
workspaceRoot,
|
||||
availabilityContext: { mode: "act" },
|
||||
});
|
||||
|
||||
expect(config.agents.map((agent) => agent.name)).toEqual([
|
||||
"reviewer",
|
||||
"subagent",
|
||||
]);
|
||||
expect(config.agents.map((agent) => agent.path)).toEqual([
|
||||
join(workspaceAgentsDir, "reviewer.yml"),
|
||||
join(globalAgentsDir, "subagent.yml"),
|
||||
]);
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,22 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename, extname } from "node:path";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import {
|
||||
ALL_DEFAULT_TOOL_NAMES,
|
||||
createAgentConfigWatcher,
|
||||
type BuiltinToolAvailabilityContext,
|
||||
discoverPluginModulePaths,
|
||||
hasMcpSettingsFile,
|
||||
listHookConfigFiles,
|
||||
listPluginTools,
|
||||
resolveAgentConfigSearchPaths,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
resolveMcpServerRegistrations,
|
||||
resolvePluginConfigSearchPaths,
|
||||
type UserInstructionConfigWatcher,
|
||||
} from "@clinebot/core";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
|
||||
export type InteractiveConfigTab =
|
||||
| "tools"
|
||||
| "workflows"
|
||||
| "agents"
|
||||
| "plugins"
|
||||
| "hooks"
|
||||
@@ -26,7 +29,12 @@ export interface InteractiveConfigItem {
|
||||
name: string;
|
||||
path: string;
|
||||
enabled?: boolean;
|
||||
source: "global" | "workspace";
|
||||
source:
|
||||
| "global"
|
||||
| "workspace"
|
||||
| "builtin"
|
||||
| "global-plugin"
|
||||
| "workspace-plugin";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -53,17 +61,84 @@ function detectSource(
|
||||
|
||||
function toSorted<T extends InteractiveConfigItem>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const sourceRank = (source: InteractiveConfigItem["source"]): number => {
|
||||
switch (source) {
|
||||
case "workspace":
|
||||
case "workspace-plugin":
|
||||
return 0;
|
||||
case "global":
|
||||
case "global-plugin":
|
||||
return 1;
|
||||
case "builtin":
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
if (a.source !== b.source) {
|
||||
return a.source === "workspace" ? -1 : 1;
|
||||
return sourceRank(a.source) - sourceRank(b.source);
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
|
||||
const agentsById = new Map<string, InteractiveConfigItem>();
|
||||
const directories = resolveAgentConfigSearchPaths(workspaceRoot).filter(
|
||||
(directory) => existsSync(directory),
|
||||
);
|
||||
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
const entries = readdirSync(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const extension = extname(entry.name).toLowerCase();
|
||||
if (extension !== ".yml" && extension !== ".yaml") {
|
||||
continue;
|
||||
}
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const frontmatter = frontmatterMatch?.[1] ?? "";
|
||||
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
const descriptionMatch = frontmatter.match(
|
||||
/^\s*description:\s*(.+?)\s*$/m,
|
||||
);
|
||||
const parsedName = nameMatch?.[1]?.replace(/^["']|["']$/g, "").trim();
|
||||
const parsedDescription = descriptionMatch?.[1]
|
||||
?.replace(/^["']|["']$/g, "")
|
||||
.trim();
|
||||
const name =
|
||||
parsedName && parsedName.length > 0
|
||||
? parsedName
|
||||
: basename(entry.name, extension);
|
||||
const id = name.toLowerCase();
|
||||
if (agentsById.has(id)) {
|
||||
continue;
|
||||
}
|
||||
agentsById.set(id, {
|
||||
id,
|
||||
name,
|
||||
path: filePath,
|
||||
enabled: true,
|
||||
source: detectSource(filePath, workspaceRoot),
|
||||
description: parsedDescription,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best effort: keep listing other agent config roots.
|
||||
}
|
||||
}
|
||||
|
||||
return [...agentsById.values()];
|
||||
}
|
||||
|
||||
export async function loadInteractiveConfigData(input: {
|
||||
watcher?: UserInstructionConfigWatcher;
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
availabilityContext?: BuiltinToolAvailabilityContext;
|
||||
}): Promise<InteractiveConfigData> {
|
||||
const workflows: InteractiveConfigItem[] = [];
|
||||
const rules: InteractiveConfigItem[] = [];
|
||||
@@ -127,25 +202,7 @@ export async function loadInteractiveConfigData(input: {
|
||||
});
|
||||
}
|
||||
|
||||
const agentWatcher = createAgentConfigWatcher();
|
||||
try {
|
||||
await agentWatcher.start();
|
||||
for (const [id, record] of agentWatcher.getSnapshot("agent").entries()) {
|
||||
const agent = record.item;
|
||||
agents.push({
|
||||
id,
|
||||
name: agent.name,
|
||||
path: record.filePath,
|
||||
enabled: true,
|
||||
source: detectSource(record.filePath, input.workspaceRoot),
|
||||
description: agent.description,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best effort: keep agents empty when watcher initialization fails.
|
||||
} finally {
|
||||
agentWatcher.stop();
|
||||
}
|
||||
agents.push(...loadAgentConfigItems(input.workspaceRoot));
|
||||
|
||||
const pluginDirectories = resolvePluginConfigSearchPaths(
|
||||
input.workspaceRoot,
|
||||
@@ -182,13 +239,33 @@ export async function loadInteractiveConfigData(input: {
|
||||
}
|
||||
}
|
||||
|
||||
for (const toolName of [...ALL_DEFAULT_TOOL_NAMES, "submit_and_exit"]) {
|
||||
tools.push(
|
||||
...getToolCatalog(input.availabilityContext).map((tool) => ({
|
||||
id: tool.id,
|
||||
name: tool.id,
|
||||
path:
|
||||
tool.headlessToolNames.length === 1 &&
|
||||
tool.headlessToolNames[0] === tool.id
|
||||
? tool.id
|
||||
: tool.headlessToolNames.join(", "),
|
||||
enabled: tool.defaultEnabled,
|
||||
source: "builtin" as const,
|
||||
description: tool.description,
|
||||
})),
|
||||
);
|
||||
for (const pluginTool of await listPluginTools({
|
||||
workspacePath: input.workspaceRoot,
|
||||
cwd: input.cwd,
|
||||
providerId: input.availabilityContext?.providerId,
|
||||
modelId: input.availabilityContext?.modelId,
|
||||
})) {
|
||||
tools.push({
|
||||
id: toolName,
|
||||
name: toolName,
|
||||
path: "(builtin)",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
id: `${pluginTool.pluginName}:${pluginTool.name}:${pluginTool.path}`,
|
||||
name: pluginTool.name,
|
||||
path: pluginTool.path,
|
||||
enabled: pluginTool.enabled,
|
||||
source: pluginTool.source,
|
||||
description: pluginTool.description,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import { StatusBar } from "./components/StatusBar";
|
||||
import { WelcomeView } from "./components/WelcomeView";
|
||||
import type {
|
||||
InteractiveConfigData,
|
||||
InteractiveConfigItem,
|
||||
InteractiveConfigTab,
|
||||
} from "./interactive-config";
|
||||
import {
|
||||
@@ -59,8 +60,12 @@ interface InteractiveTuiProps {
|
||||
initialView?: "chat" | "config";
|
||||
initialRepoStatus?: RepoStatus;
|
||||
workflowSlashCommands?: InteractiveSlashCommand[];
|
||||
loadAdditionalSlashCommands?: () => Promise<InteractiveSlashCommand[]>;
|
||||
loadWelcomeLine?: () => Promise<string | undefined>;
|
||||
loadConfigData: () => Promise<InteractiveConfigData>;
|
||||
onToggleConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
subscribeToEvents: (handlers: {
|
||||
onAgentEvent: (event: AgentEvent) => void;
|
||||
onTeamEvent: (event: TeamEvent) => void;
|
||||
@@ -253,6 +258,9 @@ export function InteractiveTui(props: InteractiveTuiProps): React.ReactElement {
|
||||
|
||||
// Slash command completion
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
||||
const [slashCommands, setSlashCommands] = useState<InteractiveSlashCommand[]>(
|
||||
() => props.workflowSlashCommands ?? [],
|
||||
);
|
||||
|
||||
const mouseOffsetX = 0;
|
||||
const mouseOffsetY = 0;
|
||||
@@ -318,10 +326,6 @@ export function InteractiveTui(props: InteractiveTuiProps): React.ReactElement {
|
||||
);
|
||||
const mentionInfo = useMemo(() => extractMentionQuery(input), [input]);
|
||||
const slashInfo = useMemo(() => extractSlashQuery(input), [input]);
|
||||
const slashCommands = useMemo(
|
||||
() => props.workflowSlashCommands ?? [],
|
||||
[props.workflowSlashCommands],
|
||||
);
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
if (!slashInfo.inSlashMode) {
|
||||
return [];
|
||||
@@ -412,7 +416,7 @@ export function InteractiveTui(props: InteractiveTuiProps): React.ReactElement {
|
||||
|
||||
const openConfigView = useCallback(() => {
|
||||
setIsConfigViewOpen(true);
|
||||
setConfigTab("skills");
|
||||
setConfigTab("tools");
|
||||
setConfigSelectedIndex(0);
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
@@ -427,10 +431,44 @@ export function InteractiveTui(props: InteractiveTuiProps): React.ReactElement {
|
||||
}
|
||||
}, [loadConfig, props.initialView]);
|
||||
|
||||
useEffect(() => {
|
||||
setSlashCommands(props.workflowSlashCommands ?? []);
|
||||
}, [props.workflowSlashCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRepoStatus();
|
||||
}, [refreshRepoStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.loadAdditionalSlashCommands) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void props
|
||||
.loadAdditionalSlashCommands()
|
||||
.then((additionalCommands) => {
|
||||
if (cancelled || additionalCommands.length === 0) {
|
||||
return;
|
||||
}
|
||||
setSlashCommands((current) => {
|
||||
const seen = new Set(current.map((command) => command.name));
|
||||
const next = [...current];
|
||||
for (const command of additionalCommands) {
|
||||
if (seen.has(command.name)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(command.name);
|
||||
next.push(command);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [props.loadAdditionalSlashCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.loadWelcomeLine) {
|
||||
setIsWelcomeLinePending(false);
|
||||
@@ -847,7 +885,7 @@ export function InteractiveTui(props: InteractiveTuiProps): React.ReactElement {
|
||||
if (typeof result.usage.totalCost === "number") {
|
||||
setLastTotalCost(result.usage.totalCost);
|
||||
}
|
||||
if (!result.commandOutput && (config.showTimings || config.showUsage)) {
|
||||
if (!result.commandOutput && config.showUsage) {
|
||||
const elapsed = ((performance.now() - startedAt) / 1000).toFixed(2);
|
||||
appendEntry({
|
||||
kind: "done",
|
||||
@@ -856,7 +894,7 @@ export function InteractiveTui(props: InteractiveTuiProps): React.ReactElement {
|
||||
config.showUsage && typeof result.usage.totalCost === "number"
|
||||
? result.usage.totalCost
|
||||
: 0,
|
||||
elapsed: config.showTimings ? elapsed : "",
|
||||
elapsed,
|
||||
iterations: result.iterations,
|
||||
});
|
||||
}
|
||||
@@ -876,7 +914,6 @@ export function InteractiveTui(props: InteractiveTuiProps): React.ReactElement {
|
||||
},
|
||||
[
|
||||
appendEntry,
|
||||
config.showTimings,
|
||||
config.showUsage,
|
||||
onSubmit,
|
||||
onTurnErrorReported,
|
||||
@@ -895,8 +932,7 @@ export function InteractiveTui(props: InteractiveTuiProps): React.ReactElement {
|
||||
}
|
||||
|
||||
if (isConfigViewOpen) {
|
||||
const isShiftTab = (key.shift && key.tab) || value === "\u001b[Z";
|
||||
const isTab = key.tab || value === "\t";
|
||||
const currentTabIndex = CONFIG_TABS.indexOf(configTab);
|
||||
if (key.escape || (key.ctrl && value === "d")) {
|
||||
closeConfigView();
|
||||
return;
|
||||
@@ -905,24 +941,27 @@ export function InteractiveTui(props: InteractiveTuiProps): React.ReactElement {
|
||||
closeConfigView();
|
||||
return;
|
||||
}
|
||||
if (isTab || isShiftTab) {
|
||||
setConfigTab((prev) => {
|
||||
const currentIndex = CONFIG_TABS.indexOf(prev);
|
||||
if (currentIndex < 0) {
|
||||
return CONFIG_TABS[0] ?? "tools";
|
||||
}
|
||||
const delta = isShiftTab ? -1 : 1;
|
||||
const nextIndex =
|
||||
(currentIndex + delta + CONFIG_TABS.length) % CONFIG_TABS.length;
|
||||
return CONFIG_TABS[nextIndex] ?? prev;
|
||||
});
|
||||
if (key.leftArrow || key.rightArrow) {
|
||||
const nextIndex =
|
||||
currentTabIndex < 0
|
||||
? 0
|
||||
: key.leftArrow
|
||||
? (currentTabIndex - 1 + CONFIG_TABS.length) % CONFIG_TABS.length
|
||||
: (currentTabIndex + 1) % CONFIG_TABS.length;
|
||||
setConfigTab(CONFIG_TABS[nextIndex] ?? "tools");
|
||||
setConfigSelectedIndex(0);
|
||||
return;
|
||||
}
|
||||
if (key.leftArrow || key.rightArrow) {
|
||||
if (value >= "1" && value <= "8") {
|
||||
const requestedIndex = Number.parseInt(value, 10) - 1;
|
||||
const nextTab = CONFIG_TABS[requestedIndex];
|
||||
if (nextTab) {
|
||||
setConfigTab(nextTab);
|
||||
setConfigSelectedIndex(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.upArrow) {
|
||||
if (key.upArrow || value === "k") {
|
||||
if (activeConfigItems.length > 0) {
|
||||
setConfigSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : activeConfigItems.length - 1,
|
||||
@@ -930,7 +969,7 @@ export function InteractiveTui(props: InteractiveTuiProps): React.ReactElement {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
if (key.downArrow || value === "j") {
|
||||
if (activeConfigItems.length > 0) {
|
||||
setConfigSelectedIndex((prev) =>
|
||||
prev < activeConfigItems.length - 1 ? prev + 1 : 0,
|
||||
@@ -940,6 +979,25 @@ export function InteractiveTui(props: InteractiveTuiProps): React.ReactElement {
|
||||
}
|
||||
if (key.return) {
|
||||
const selected = activeConfigItems[configSelectedIndex];
|
||||
if (
|
||||
selected &&
|
||||
configTab === "tools" &&
|
||||
(selected.source === "workspace-plugin" ||
|
||||
selected.source === "global-plugin")
|
||||
) {
|
||||
setIsLoadingConfig(true);
|
||||
void props
|
||||
.onToggleConfigItem?.(selected)
|
||||
.then((nextData) => {
|
||||
if (nextData) {
|
||||
setConfigData(nextData);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingConfig(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (selected && configTab === "skills") {
|
||||
const nextInput = `/${selected.name} `;
|
||||
setInput(nextInput);
|
||||
|
||||
@@ -52,6 +52,11 @@ export function listInteractiveSlashCommands(
|
||||
instructions: "",
|
||||
description: "Alias for /config",
|
||||
},
|
||||
{
|
||||
name: "fork",
|
||||
instructions: "/fork",
|
||||
description: "Create a copy of the current session into a new session",
|
||||
},
|
||||
{
|
||||
name: "team",
|
||||
instructions: "/team [prompt]",
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ToolApprovalRequest, ToolApprovalResult } from "@clinebot/shared";
|
||||
import { truncate } from "./helpers";
|
||||
import { c, getActiveCliSession, write } from "./output";
|
||||
|
||||
const SHOW_TERMINAL_CURSOR = "\x1b[?25h";
|
||||
|
||||
// =============================================================================
|
||||
// Desktop tool approval
|
||||
// =============================================================================
|
||||
@@ -129,28 +131,31 @@ export async function askQuestionInTerminal(
|
||||
for (const [index, option] of options.entries()) {
|
||||
write(`${c.dim} ${index + 1}.${c.reset} ${option}\n`);
|
||||
}
|
||||
|
||||
rl.question(
|
||||
`${c.dim}Choose 1-${options.length} or type a custom answer:${c.reset} `,
|
||||
(value) => {
|
||||
rl.close();
|
||||
const trimmed = value.trim();
|
||||
const numeric = Number.parseInt(trimmed, 10);
|
||||
if (
|
||||
Number.isInteger(numeric) &&
|
||||
numeric >= 1 &&
|
||||
numeric <= options.length
|
||||
) {
|
||||
resolve(options[numeric - 1] ?? "");
|
||||
return;
|
||||
}
|
||||
if (trimmed.length > 0) {
|
||||
resolve(trimmed);
|
||||
return;
|
||||
}
|
||||
resolve(options[0] ?? "");
|
||||
},
|
||||
// Ink hides the terminal cursor while its TUI is mounted; restore it so
|
||||
// readline shows a normal blinking insertion point for the follow-up.
|
||||
write(SHOW_TERMINAL_CURSOR);
|
||||
write(
|
||||
`${c.dim}Choose 1-${options.length} or type a custom answer:${c.reset}\n${c.green}>${c.reset} `,
|
||||
);
|
||||
|
||||
rl.question("", (value) => {
|
||||
rl.close();
|
||||
const trimmed = value.trim();
|
||||
const numeric = Number.parseInt(trimmed, 10);
|
||||
if (
|
||||
Number.isInteger(numeric) &&
|
||||
numeric >= 1 &&
|
||||
numeric <= options.length
|
||||
) {
|
||||
resolve(options[numeric - 1] ?? "");
|
||||
return;
|
||||
}
|
||||
if (trimmed.length > 0) {
|
||||
resolve(trimmed);
|
||||
return;
|
||||
}
|
||||
resolve(options[0] ?? "");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,103 @@ describe("chat commands", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("runs /fork and replies with forked session ids", async () => {
|
||||
const reply = vi.fn(async () => undefined);
|
||||
const fork = vi.fn(async () => ({
|
||||
forkedFromSessionId: "sess_original",
|
||||
newSessionId: "sess_fork",
|
||||
}));
|
||||
|
||||
const handled = await maybeHandleChatCommand("/fork", {
|
||||
enabled: true,
|
||||
getState: async () => ({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp",
|
||||
workspaceRoot: "/tmp",
|
||||
}),
|
||||
setState: async () => undefined,
|
||||
reply,
|
||||
fork,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(fork).toHaveBeenCalledTimes(1);
|
||||
expect(reply).toHaveBeenCalledWith(
|
||||
"Forked session sess_original → new session sess_fork",
|
||||
);
|
||||
});
|
||||
|
||||
it("replies with failure message when fork returns undefined", async () => {
|
||||
const reply = vi.fn(async () => undefined);
|
||||
const fork = vi.fn(async () => undefined);
|
||||
|
||||
const handled = await maybeHandleChatCommand("/fork", {
|
||||
enabled: true,
|
||||
getState: async () => ({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp",
|
||||
workspaceRoot: "/tmp",
|
||||
}),
|
||||
setState: async () => undefined,
|
||||
reply,
|
||||
fork,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(fork).toHaveBeenCalledTimes(1);
|
||||
expect(reply).toHaveBeenCalledWith(
|
||||
"Fork failed: could not read messages from the current session.",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces thrown error message when fork throws", async () => {
|
||||
const reply = vi.fn(async () => undefined);
|
||||
const fork = vi.fn(async () => {
|
||||
throw new Error("Cannot fork an empty session.");
|
||||
});
|
||||
|
||||
const handled = await maybeHandleChatCommand("/fork", {
|
||||
enabled: true,
|
||||
getState: async () => ({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp",
|
||||
workspaceRoot: "/tmp",
|
||||
}),
|
||||
setState: async () => undefined,
|
||||
reply,
|
||||
fork,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(fork).toHaveBeenCalledTimes(1);
|
||||
expect(reply).toHaveBeenCalledWith("Cannot fork an empty session.");
|
||||
});
|
||||
|
||||
it("ignores /fork when fork callback is not provided", async () => {
|
||||
const reply = vi.fn(async () => undefined);
|
||||
|
||||
const handled = await maybeHandleChatCommand("/fork", {
|
||||
enabled: true,
|
||||
getState: async () => ({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp",
|
||||
workspaceRoot: "/tmp",
|
||||
}),
|
||||
setState: async () => undefined,
|
||||
reply,
|
||||
// No fork callback — command should not be available
|
||||
});
|
||||
|
||||
// isAvailable returns false when fork is not defined, so the command
|
||||
// is not matched and the handler returns false.
|
||||
expect(handled).toBe(false);
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs /abort without disconnecting", async () => {
|
||||
const abort = vi.fn(async () => undefined);
|
||||
const reply = vi.fn(async () => undefined);
|
||||
|
||||
@@ -9,6 +9,11 @@ export type ChatCommandState = {
|
||||
workspaceRoot: string;
|
||||
};
|
||||
|
||||
export type ForkSessionResult = {
|
||||
forkedFromSessionId: string;
|
||||
newSessionId: string;
|
||||
};
|
||||
|
||||
export type ChatCommandContext = {
|
||||
enabled: boolean;
|
||||
host?: ChatCommandHost;
|
||||
@@ -19,6 +24,10 @@ export type ChatCommandContext = {
|
||||
abort?: () => Promise<void> | void;
|
||||
stop?: () => Promise<void> | void;
|
||||
describe?: () => Promise<string> | string;
|
||||
fork?: () =>
|
||||
| Promise<ForkSessionResult | undefined>
|
||||
| ForkSessionResult
|
||||
| undefined;
|
||||
schedule?: {
|
||||
create?: (input: {
|
||||
name: string;
|
||||
@@ -317,6 +326,32 @@ function createDefaultChatCommandHost(): ChatCommandHost {
|
||||
);
|
||||
},
|
||||
})
|
||||
.register("command", {
|
||||
names: ["/fork"],
|
||||
isAvailable: (context) => typeof context.fork === "function",
|
||||
run: async (_parsed, context) => {
|
||||
let result: ForkSessionResult | undefined;
|
||||
try {
|
||||
result = await context.fork?.();
|
||||
} catch (error) {
|
||||
await context.reply(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Fork failed: could not read messages from the current session.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!result) {
|
||||
await context.reply(
|
||||
"Fork failed: could not read messages from the current session.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await context.reply(
|
||||
`Forked session ${result.forkedFromSessionId} → new session ${result.newSessionId}`,
|
||||
);
|
||||
},
|
||||
})
|
||||
.register("command", {
|
||||
names: ["/schedule"],
|
||||
run: async ({ args }, context) => {
|
||||
|
||||
@@ -73,6 +73,35 @@ describe("handleEvent text formatting", () => {
|
||||
expect(output).toMatch(/\[run_commands\].*\n.*\[read_files\]/s);
|
||||
});
|
||||
|
||||
it("does not echo ask_question through the generic tool renderer", () => {
|
||||
handleEvent(
|
||||
{
|
||||
type: "content_start",
|
||||
contentType: "tool",
|
||||
toolName: "ask_question",
|
||||
input: {
|
||||
question: "How can I best assist you today?",
|
||||
options: [
|
||||
"Help me understand or analyze code in a repository",
|
||||
"Help me create or edit files",
|
||||
],
|
||||
},
|
||||
} as unknown as AgentEvent,
|
||||
{} as Config,
|
||||
);
|
||||
handleEvent(
|
||||
{
|
||||
type: "content_end",
|
||||
contentType: "tool",
|
||||
toolName: "ask_question",
|
||||
output: "Help me create or edit files",
|
||||
} as unknown as AgentEvent,
|
||||
{} as Config,
|
||||
);
|
||||
|
||||
expect(output).toBe("");
|
||||
});
|
||||
|
||||
it("prints tool errors inline", () => {
|
||||
handleEvent(
|
||||
{
|
||||
|
||||
@@ -115,6 +115,9 @@ export function handleEvent(event: AgentEvent, config: Config): void {
|
||||
closeInlineStreamIfNeeded();
|
||||
const toolName = event.toolName ?? "unknown_tool";
|
||||
const inputStr = formatToolInput(toolName, event.input);
|
||||
if (toolName === "ask_question") {
|
||||
break;
|
||||
}
|
||||
write(
|
||||
`${c.cyan}[${toolName}]${c.reset}${inputStr ? ` ${inputStr}` : ""}\n`,
|
||||
);
|
||||
@@ -131,6 +134,9 @@ export function handleEvent(event: AgentEvent, config: Config): void {
|
||||
break;
|
||||
case "tool":
|
||||
closeInlineStreamIfNeeded();
|
||||
if (event.toolName === "ask_question") {
|
||||
break;
|
||||
}
|
||||
if (event.error) {
|
||||
write(
|
||||
` ${c.gray}${HOOK}${c.reset}${c.red}error: ${event.error}${c.reset}\n`,
|
||||
|
||||
@@ -45,7 +45,6 @@ describe("parseArgs", () => {
|
||||
verbose: false,
|
||||
interactive: false,
|
||||
showUsage: false,
|
||||
showTimings: false,
|
||||
outputMode: "text",
|
||||
mode: "act",
|
||||
sandbox: false,
|
||||
@@ -54,32 +53,19 @@ describe("parseArgs", () => {
|
||||
reasoningEffort: undefined,
|
||||
liveModelCatalog: false,
|
||||
yolo: false,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: true,
|
||||
enableTools: true,
|
||||
defaultToolAutoApprove: true,
|
||||
toolPolicies: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses prompt, runtime flags, and global approval settings", () => {
|
||||
const parsed = parseArgs([
|
||||
"--verbose",
|
||||
"--no-tools",
|
||||
"--no-spawn",
|
||||
"--no-teams",
|
||||
"--autoapprove",
|
||||
"false",
|
||||
"--tool-enable",
|
||||
"read_files",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--team-name",
|
||||
"dev-team",
|
||||
"--mission-step-interval",
|
||||
"4",
|
||||
"--mission-time-interval-ms",
|
||||
"25000",
|
||||
"--provider",
|
||||
"openai",
|
||||
"--model",
|
||||
@@ -87,7 +73,6 @@ describe("parseArgs", () => {
|
||||
"--key",
|
||||
"abc123",
|
||||
"--usage",
|
||||
"--timings",
|
||||
"--thinking",
|
||||
"--reasoning-effort",
|
||||
"high",
|
||||
@@ -100,12 +85,8 @@ describe("parseArgs", () => {
|
||||
|
||||
expect(parsed.prompt).toBe("Audit the repo");
|
||||
expect(parsed.verbose).toBe(true);
|
||||
expect(parsed.enableTools).toBe(false);
|
||||
expect(parsed.enableSpawnAgent).toBe(false);
|
||||
expect(parsed.enableAgentTeams).toBe(false);
|
||||
expect(parsed.defaultToolAutoApprove).toBe(false);
|
||||
expect(parsed.showUsage).toBe(true);
|
||||
expect(parsed.showTimings).toBe(true);
|
||||
expect(parsed.thinking).toBe(true);
|
||||
expect(parsed.reasoningEffort).toBe("high");
|
||||
expect(parsed.liveModelCatalog).toBe(true);
|
||||
@@ -113,15 +94,10 @@ describe("parseArgs", () => {
|
||||
expect(parsed.mode).toBe("plan");
|
||||
expect(parsed.cwd).toBe("/tmp/work");
|
||||
expect(parsed.teamName).toBe("dev-team");
|
||||
expect(parsed.missionLogIntervalSteps).toBe(4);
|
||||
expect(parsed.missionLogIntervalMs).toBe(25000);
|
||||
expect(parsed.provider).toBe("openai");
|
||||
expect(parsed.model).toBe("gpt-5");
|
||||
expect(parsed.key).toBe("abc123");
|
||||
expect(parsed.sandbox).toBe(false);
|
||||
expect(parsed.toolPolicies).toEqual({
|
||||
read_files: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("parses provider via -P shorthand", () => {
|
||||
@@ -135,26 +111,6 @@ describe("parseArgs", () => {
|
||||
expect(parsed.sandboxDir).toBe("./.tmp-cline");
|
||||
});
|
||||
|
||||
it("ignores empty tool names for enable/disable policy flags", () => {
|
||||
const parsed = parseArgs(["--tool-enable", "", "--tool-disable", ""]);
|
||||
expect(parsed.toolPolicies).toEqual({});
|
||||
});
|
||||
|
||||
it("splits comma-separated tool policy flags", () => {
|
||||
const parsed = parseArgs([
|
||||
"--tool-enable",
|
||||
"search_codebase,fetch_web_content",
|
||||
"--tool-disable",
|
||||
"run_commands,read_files",
|
||||
]);
|
||||
expect(parsed.toolPolicies).toEqual({
|
||||
search_codebase: { enabled: true },
|
||||
fetch_web_content: { enabled: true },
|
||||
run_commands: { enabled: false },
|
||||
read_files: { enabled: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("parses --autoapprove false as global approval-off", () => {
|
||||
const parsed = parseArgs([
|
||||
"--autoapprove",
|
||||
@@ -162,7 +118,6 @@ describe("parseArgs", () => {
|
||||
"tell me about this repo",
|
||||
]);
|
||||
expect(parsed.defaultToolAutoApprove).toBe(false);
|
||||
expect(parsed.toolPolicies).toEqual({});
|
||||
expect(parsed.prompt).toBe("tell me about this repo");
|
||||
});
|
||||
|
||||
@@ -211,22 +166,9 @@ describe("parseArgs", () => {
|
||||
expect(parsed.invalidMaxConsecutiveMistakes).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports yolo and auto-approve-all aliases for tool auto-approval", () => {
|
||||
it("supports yolo as an auto-approval shortcut", () => {
|
||||
const parsedYolo = parseArgs(["--yolo"]);
|
||||
expect(parsedYolo.defaultToolAutoApprove).toBe(true);
|
||||
expect(parsedYolo.enableSpawnAgent).toBe(false);
|
||||
expect(parsedYolo.enableAgentTeams).toBe(false);
|
||||
|
||||
const parsedAutoApproveAll = parseArgs(["--auto-approve-all"]);
|
||||
expect(parsedAutoApproveAll.defaultToolAutoApprove).toBe(true);
|
||||
expect(parsedAutoApproveAll.enableSpawnAgent).toBe(true);
|
||||
expect(parsedAutoApproveAll.enableAgentTeams).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves explicit spawn and team opt-ins in yolo mode", () => {
|
||||
const parsed = parseArgs(["--yolo", "--spawn", "--teams"]);
|
||||
expect(parsed.enableSpawnAgent).toBe(true);
|
||||
expect(parsed.enableAgentTeams).toBe(true);
|
||||
});
|
||||
|
||||
it("parses timeout and validates invalid values", () => {
|
||||
@@ -331,6 +273,28 @@ describe("format helpers", () => {
|
||||
).toContain("send lead:");
|
||||
});
|
||||
|
||||
it("formats ask_question as a readable prompt", () => {
|
||||
expect(
|
||||
formatToolInput("ask_question", {
|
||||
question: "How can I best assist you today?",
|
||||
options: [
|
||||
"Help me understand or analyze code in a repository",
|
||||
"Help me create or edit files",
|
||||
"Help me run commands or tests",
|
||||
],
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"The agent is waiting for your input.",
|
||||
"How can I best assist you today?",
|
||||
"1. Help me understand or analyze code in a repository",
|
||||
"2. Help me create or edit files",
|
||||
"3. Help me run commands or tests",
|
||||
"> Reply with an option number or type your answer.",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("summarizes structured tool outputs", () => {
|
||||
expect(formatToolOutput("simple text output")).toBe("simple text output");
|
||||
expect(
|
||||
|
||||
@@ -101,6 +101,30 @@ function summarizeRunCommandsInput(input: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatAskQuestionInput(input: Record<string, unknown>): string {
|
||||
const question =
|
||||
typeof input.question === "string" ? input.question.trim() : "";
|
||||
const options = Array.isArray(input.options)
|
||||
? input.options
|
||||
.map((option) => String(option).trim())
|
||||
.filter((option) => option.length > 0)
|
||||
: [];
|
||||
|
||||
if (!question && options.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines = ["The agent is waiting for your input."];
|
||||
if (question) {
|
||||
lines.push(question);
|
||||
}
|
||||
for (const [index, option] of options.entries()) {
|
||||
lines.push(`${index + 1}. ${option}`);
|
||||
}
|
||||
lines.push("> Reply with an option number or type your answer.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function formatToolInput(toolName: string, input: unknown): string {
|
||||
if (!input) {
|
||||
return "";
|
||||
@@ -117,6 +141,8 @@ export function formatToolInput(toolName: string, input: unknown): string {
|
||||
const obj = input as Record<string, unknown>;
|
||||
|
||||
switch (toolName) {
|
||||
case "ask_question":
|
||||
return formatAskQuestionInput(obj);
|
||||
case "read_files":
|
||||
if (Array.isArray(obj.file_paths)) {
|
||||
return truncate(obj.file_paths.join(", "), 120);
|
||||
|
||||
@@ -1,54 +1,50 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@clinebot/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@clinebot/core")>("@clinebot/core");
|
||||
return {
|
||||
...actual,
|
||||
createPersistentSubprocessHooks: vi.fn(() => ({
|
||||
hooks: {},
|
||||
client: { close: vi.fn() },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeHooks } from "./hooks";
|
||||
|
||||
describe("createRuntimeHooks", () => {
|
||||
const originalArgv = process.argv.slice();
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv.slice();
|
||||
process.env = { ...originalEnv };
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("disables runtime hooks in yolo mode", async () => {
|
||||
process.argv = [process.argv[0] || "node", "/tmp/clite.js"];
|
||||
|
||||
const runtimeHooks = createRuntimeHooks({ yolo: true });
|
||||
const runtimeHooks = createRuntimeHooks({
|
||||
yolo: true,
|
||||
dispatchHookEvent: vi.fn(),
|
||||
});
|
||||
|
||||
expect(runtimeHooks.hooks).toBeUndefined();
|
||||
await expect(runtimeHooks.shutdown()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns hooks when the CLI entrypoint is available", async () => {
|
||||
process.argv = [process.argv[0] || "node", "/tmp/clite.js"];
|
||||
|
||||
const runtimeHooks = createRuntimeHooks({ yolo: false });
|
||||
it("returns in-process hooks when dispatch is available", async () => {
|
||||
const dispatchHookEvent = vi.fn().mockResolvedValue(undefined);
|
||||
const runtimeHooks = createRuntimeHooks({
|
||||
yolo: false,
|
||||
cwd: "/workspace",
|
||||
workspaceRoot: "/workspace",
|
||||
dispatchHookEvent,
|
||||
});
|
||||
|
||||
expect(runtimeHooks.hooks).toBeDefined();
|
||||
await expect(runtimeHooks.shutdown()).resolves.toBeUndefined();
|
||||
});
|
||||
await runtimeHooks.hooks?.onRunStart?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "session-1",
|
||||
parentAgentId: null,
|
||||
userMessage: "hello",
|
||||
});
|
||||
|
||||
it("disables runtime hooks for internal hook-worker processes", async () => {
|
||||
process.argv = [process.argv[0] || "node", "/tmp/clite.js"];
|
||||
process.env.CLINE_INTERNAL_ROLE = "hook-worker";
|
||||
|
||||
const runtimeHooks = createRuntimeHooks({ yolo: false });
|
||||
|
||||
expect(runtimeHooks.hooks).toBeUndefined();
|
||||
await expect(runtimeHooks.shutdown()).resolves.toBeUndefined();
|
||||
expect(dispatchHookEvent).toHaveBeenCalledTimes(2);
|
||||
expect(dispatchHookEvent).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
hookName: "agent_start",
|
||||
taskId: "session-1",
|
||||
workspaceRoots: ["/workspace"],
|
||||
}),
|
||||
);
|
||||
expect(dispatchHookEvent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
hookName: "prompt_submit",
|
||||
taskId: "session-1",
|
||||
workspaceRoots: ["/workspace"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+254
-114
@@ -1,21 +1,5 @@
|
||||
import type {
|
||||
AgentHooks,
|
||||
HookEventPayload,
|
||||
PersistentSubprocessHooksOptions,
|
||||
RunHookResult,
|
||||
} from "@clinebot/core";
|
||||
import {
|
||||
createPersistentSubprocessHooks,
|
||||
type HookSessionContext,
|
||||
} from "@clinebot/core";
|
||||
import { formatHookDispatchOutput } from "../commands/hook";
|
||||
import { logSpawnedProcess } from "../logging/process";
|
||||
import type { AgentHooks, HookEventPayload } from "@clinebot/core";
|
||||
import { closeInlineStreamIfNeeded } from "./events";
|
||||
import {
|
||||
buildCliSubcommandCommand,
|
||||
buildInternalCliEnv,
|
||||
shouldDisableInternalRuntimeHooks,
|
||||
} from "./internal-launch";
|
||||
import {
|
||||
c,
|
||||
emitJsonLine,
|
||||
@@ -27,34 +11,7 @@ import {
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
function hasHookControlOutput(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
record.cancel === true ||
|
||||
record.review === true ||
|
||||
(typeof record.context === "string" && record.context.trim().length > 0) ||
|
||||
(typeof record.contextModification === "string" &&
|
||||
record.contextModification.trim().length > 0) ||
|
||||
(typeof record.errorMessage === "string" &&
|
||||
record.errorMessage.trim().length > 0) ||
|
||||
Object.hasOwn(record, "overrideInput")
|
||||
);
|
||||
}
|
||||
|
||||
function getHookCommand(): string[] | undefined {
|
||||
const command = buildCliSubcommandCommand("hook");
|
||||
return command ? [command.launcher, ...command.childArgs] : undefined;
|
||||
}
|
||||
|
||||
function getHookWorkerCommand(): string[] | undefined {
|
||||
const command = buildCliSubcommandCommand("hook-worker");
|
||||
return command ? [command.launcher, ...command.childArgs] : undefined;
|
||||
}
|
||||
|
||||
export function currentHookSessionContext(): HookSessionContext | undefined {
|
||||
function currentHookSessionContext(): { rootSessionId: string } | undefined {
|
||||
const session = getActiveCliSession();
|
||||
if (!session) {
|
||||
return undefined;
|
||||
@@ -67,13 +24,11 @@ export function currentHookSessionContext(): HookSessionContext | undefined {
|
||||
function writeHookInvocation(
|
||||
payload: HookEventPayload,
|
||||
options: { verbose: boolean },
|
||||
result?: RunHookResult,
|
||||
): void {
|
||||
if (getCurrentOutputMode() === "json") {
|
||||
emitJsonLine("stdout", {
|
||||
type: "hook_event",
|
||||
hookEventName: payload.hookName,
|
||||
hookOutput: result?.parsedJson,
|
||||
agentId: payload.agent_id,
|
||||
taskId: payload.taskId,
|
||||
parentAgentId: payload.parent_agent_id,
|
||||
@@ -81,12 +36,9 @@ function writeHookInvocation(
|
||||
return;
|
||||
}
|
||||
if (!options.verbose) {
|
||||
if (payload.hookName === "tool_result") {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
payload.hookName === "tool_call" &&
|
||||
!hasHookControlOutput(result?.parsedJson)
|
||||
payload.hookName === "tool_result" ||
|
||||
payload.hookName === "tool_call"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -100,85 +52,273 @@ function writeHookInvocation(
|
||||
? payload.tool_result.name
|
||||
: undefined;
|
||||
const details = toolName ? ` ${c.cyan}${toolName}${c.reset}` : "";
|
||||
const output = formatHookDispatchOutput(result);
|
||||
if (output) {
|
||||
write(
|
||||
`\n${c.dim}[hook:${hookName}]${c.reset}${details} ${c.dim}-> ${output}${c.reset}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (details) {
|
||||
write(`\n${c.dim}[hook:${hookName}]${c.reset}${details}\n`);
|
||||
return;
|
||||
}
|
||||
write(`\n${c.dim}[hook:${hookName}]${c.reset}\n`);
|
||||
}
|
||||
|
||||
type HookRuntimeBaseContext = {
|
||||
agentId: string;
|
||||
conversationId: string;
|
||||
parentAgentId: string | null;
|
||||
};
|
||||
|
||||
type AgentHookRunStartContext = Parameters<
|
||||
NonNullable<AgentHooks["onRunStart"]>
|
||||
>[0];
|
||||
type AgentHookStopErrorContext = Parameters<
|
||||
NonNullable<AgentHooks["onStopError"]>
|
||||
>[0];
|
||||
type AgentHookToolCallEndContext = Parameters<
|
||||
NonNullable<AgentHooks["onToolCallEnd"]>
|
||||
>[0];
|
||||
type AgentHookToolCallStartContext = Parameters<
|
||||
NonNullable<AgentHooks["onToolCallStart"]>
|
||||
>[0];
|
||||
type AgentHookTurnEndContext = Parameters<
|
||||
NonNullable<AgentHooks["onTurnEnd"]>
|
||||
>[0];
|
||||
type AgentHookSessionShutdownContext = Parameters<
|
||||
NonNullable<AgentHooks["onSessionShutdown"]>
|
||||
>[0];
|
||||
|
||||
function mapParams(input: unknown): Record<string, string> {
|
||||
if (!input || typeof input !== "object") {
|
||||
return {};
|
||||
}
|
||||
const output: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
output[key] = typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function isAbortReason(reason?: string): boolean {
|
||||
const value = String(reason ?? "").toLowerCase();
|
||||
return (
|
||||
value.includes("cancel") ||
|
||||
value.includes("abort") ||
|
||||
value.includes("interrupt")
|
||||
);
|
||||
}
|
||||
|
||||
function serializeHookError(error: Error): {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
} {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
}
|
||||
|
||||
function basePayload(
|
||||
ctx: HookRuntimeBaseContext,
|
||||
options: { cwd: string; workspaceRoot: string },
|
||||
): Omit<HookEventPayload, "hookName"> {
|
||||
const userId =
|
||||
process.env.CLINE_USER_ID?.trim() || process.env.USER?.trim() || "unknown";
|
||||
const sessionContext = currentHookSessionContext();
|
||||
return {
|
||||
clineVersion: process.env.CLINE_VERSION?.trim() || "",
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId: ctx.conversationId,
|
||||
...(sessionContext ? { sessionContext } : {}),
|
||||
workspaceRoots: [options.workspaceRoot || options.cwd].filter(Boolean),
|
||||
userId,
|
||||
agent_id: ctx.agentId,
|
||||
parent_agent_id: ctx.parentAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchHookPayload(
|
||||
payload: HookEventPayload,
|
||||
options: {
|
||||
dispatchHookEvent: (payload: HookEventPayload) => Promise<void>;
|
||||
verbose: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await options.dispatchHookEvent(payload);
|
||||
writeHookInvocation(payload, { verbose: options.verbose });
|
||||
} catch (error) {
|
||||
if (isDev) {
|
||||
writeErr(
|
||||
`hook dispatch failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createRuntimeHooks(options?: {
|
||||
export function createRuntimeHooks(options: {
|
||||
verbose?: boolean;
|
||||
yolo?: boolean;
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
dispatchHookEvent: (payload: HookEventPayload) => Promise<void>;
|
||||
}): {
|
||||
hooks?: AgentHooks;
|
||||
shutdown: () => Promise<void>;
|
||||
} {
|
||||
if (options?.yolo === true || shouldDisableInternalRuntimeHooks()) {
|
||||
if (options.yolo === true) {
|
||||
return {
|
||||
hooks: undefined,
|
||||
shutdown: async () => {},
|
||||
};
|
||||
}
|
||||
const hookCommand = getHookCommand();
|
||||
const workerCommand = getHookWorkerCommand();
|
||||
if (!hookCommand || !workerCommand) {
|
||||
return {
|
||||
hooks: undefined,
|
||||
shutdown: async () => {},
|
||||
};
|
||||
}
|
||||
const verbose = options?.verbose === true;
|
||||
const sharedOptions: Omit<PersistentSubprocessHooksOptions, "command"> = {
|
||||
env: buildInternalCliEnv("hook-worker"),
|
||||
cwd: process.cwd(),
|
||||
sessionContext: currentHookSessionContext,
|
||||
onDispatchError: (error: Error) => {
|
||||
if (isDev) {
|
||||
writeErr(`hook dispatch failed: ${error.message}`);
|
||||
}
|
||||
},
|
||||
onDispatch: ({
|
||||
payload,
|
||||
result,
|
||||
}: {
|
||||
payload: HookEventPayload;
|
||||
result?: RunHookResult;
|
||||
detached: boolean;
|
||||
}) => {
|
||||
writeHookInvocation(payload, { verbose }, result);
|
||||
},
|
||||
onSpawn: ({
|
||||
command,
|
||||
pid,
|
||||
detached,
|
||||
}: {
|
||||
command: string[];
|
||||
pid?: number;
|
||||
detached: boolean;
|
||||
}) => {
|
||||
logSpawnedProcess({
|
||||
component: "hooks",
|
||||
command,
|
||||
childPid: pid,
|
||||
detached,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
},
|
||||
};
|
||||
const control = createPersistentSubprocessHooks({
|
||||
...sharedOptions,
|
||||
command: workerCommand,
|
||||
});
|
||||
const verbose = options.verbose === true;
|
||||
const cwd = options.cwd?.trim() || process.cwd();
|
||||
const workspaceRoot = options.workspaceRoot?.trim() || cwd;
|
||||
return {
|
||||
hooks: control.hooks,
|
||||
shutdown: async () => {
|
||||
await control.client.close();
|
||||
hooks: {
|
||||
onRunStart: async (ctx: AgentHookRunStartContext) => {
|
||||
const root = basePayload(ctx, { cwd, workspaceRoot });
|
||||
const isResume = process.env.CLINE_HOOK_AGENT_RESUME === "1";
|
||||
await dispatchHookPayload(
|
||||
isResume
|
||||
? {
|
||||
...root,
|
||||
hookName: "agent_resume",
|
||||
taskResume: {
|
||||
taskMetadata: {},
|
||||
previousState: {},
|
||||
},
|
||||
}
|
||||
: {
|
||||
...root,
|
||||
hookName: "agent_start",
|
||||
taskStart: { taskMetadata: {} },
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
await dispatchHookPayload(
|
||||
{
|
||||
...basePayload(ctx, { cwd, workspaceRoot }),
|
||||
hookName: "prompt_submit",
|
||||
userPromptSubmit: {
|
||||
prompt: ctx.userMessage,
|
||||
attachments: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
onToolCallStart: async (ctx: AgentHookToolCallStartContext) => {
|
||||
await dispatchHookPayload(
|
||||
{
|
||||
...basePayload(ctx, { cwd, workspaceRoot }),
|
||||
hookName: "tool_call",
|
||||
iteration: ctx.iteration,
|
||||
tool_call: {
|
||||
id: ctx.call.id,
|
||||
name: ctx.call.name,
|
||||
input: ctx.call.input,
|
||||
},
|
||||
preToolUse: {
|
||||
toolName: ctx.call.name,
|
||||
parameters: mapParams(ctx.call.input),
|
||||
},
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
onToolCallEnd: async (ctx: AgentHookToolCallEndContext) => {
|
||||
await dispatchHookPayload(
|
||||
{
|
||||
...basePayload(ctx, { cwd, workspaceRoot }),
|
||||
hookName: "tool_result",
|
||||
iteration: ctx.iteration,
|
||||
tool_result: ctx.record,
|
||||
postToolUse: {
|
||||
toolName: ctx.record.name,
|
||||
parameters: mapParams(ctx.record.input),
|
||||
result:
|
||||
typeof ctx.record.output === "string"
|
||||
? ctx.record.output
|
||||
: JSON.stringify(ctx.record.output),
|
||||
success: !ctx.record.error,
|
||||
executionTimeMs: ctx.record.durationMs,
|
||||
},
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
onTurnEnd: async (ctx: AgentHookTurnEndContext) => {
|
||||
await dispatchHookPayload(
|
||||
{
|
||||
...basePayload(ctx, { cwd, workspaceRoot }),
|
||||
hookName: "agent_end",
|
||||
iteration: ctx.iteration,
|
||||
turn: ctx.turn,
|
||||
taskComplete: { taskMetadata: {} },
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
onStopError: async (ctx: AgentHookStopErrorContext) => {
|
||||
const hookName = isAbortReason(ctx.error.message)
|
||||
? "agent_abort"
|
||||
: "agent_error";
|
||||
await dispatchHookPayload(
|
||||
hookName === "agent_abort"
|
||||
? {
|
||||
...basePayload(ctx, { cwd, workspaceRoot }),
|
||||
hookName,
|
||||
reason: ctx.error.message,
|
||||
taskCancel: { taskMetadata: {} },
|
||||
}
|
||||
: {
|
||||
...basePayload(ctx, { cwd, workspaceRoot }),
|
||||
hookName,
|
||||
iteration: ctx.iteration,
|
||||
error: serializeHookError(ctx.error),
|
||||
taskCancel: { taskMetadata: {} },
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
onSessionShutdown: async (ctx: AgentHookSessionShutdownContext) => {
|
||||
await dispatchHookPayload(
|
||||
{
|
||||
...basePayload(ctx, { cwd, workspaceRoot }),
|
||||
hookName: "session_shutdown",
|
||||
reason: ctx.reason,
|
||||
},
|
||||
{
|
||||
dispatchHookEvent: options.dispatchHookEvent,
|
||||
verbose,
|
||||
},
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
shutdown: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
resolveClineDataDir,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "@clinebot/core";
|
||||
import {
|
||||
type HubEndpointOverrides,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
} from "@clinebot/hub";
|
||||
import { withResolvedClineBuildEnv } from "@clinebot/shared";
|
||||
import { createCliLoggerAdapter } from "../logging/adapter";
|
||||
import { buildCliSubcommandCommand } from "./internal-launch";
|
||||
|
||||
function getHubCommandLogger() {
|
||||
return createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
component: "hub",
|
||||
}).core;
|
||||
}
|
||||
|
||||
function openDetachedHubLogFile(): { fd: number; logPath: string } | undefined {
|
||||
try {
|
||||
const logPath = join(resolveClineDataDir(), "logs", "hub-sidecar.log");
|
||||
mkdirSync(dirname(logPath), { recursive: true });
|
||||
return { fd: openSync(logPath, "a"), logPath };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function endpointArgs(endpoint: HubEndpointOverrides): string[] {
|
||||
return [
|
||||
...(endpoint.host ? ["--host", endpoint.host] : []),
|
||||
...(typeof endpoint.port === "number"
|
||||
? ["--port", String(endpoint.port)]
|
||||
: []),
|
||||
...(endpoint.pathname ? ["--pathname", endpoint.pathname] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export function parseHubEndpointOverride(
|
||||
rawAddress: string | undefined,
|
||||
): HubEndpointOverrides {
|
||||
const trimmed = rawAddress?.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(
|
||||
trimmed.includes("://") ? trimmed : `ws://${trimmed}`,
|
||||
);
|
||||
return {
|
||||
host: parsed.hostname || undefined,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
pathname:
|
||||
parsed.pathname && parsed.pathname !== "/"
|
||||
? parsed.pathname
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnCliHubStartDetached(
|
||||
workspaceRoot: string,
|
||||
endpoint: HubEndpointOverrides,
|
||||
): void {
|
||||
const logger = getHubCommandLogger();
|
||||
const command = buildCliSubcommandCommand("hub", [
|
||||
"start",
|
||||
"--cwd",
|
||||
workspaceRoot,
|
||||
...endpointArgs(endpoint),
|
||||
]);
|
||||
if (!command) {
|
||||
throw new Error("unable to resolve CLI entrypoint for detached hub start");
|
||||
}
|
||||
|
||||
const sidecarLog = openDetachedHubLogFile();
|
||||
try {
|
||||
const child = spawn(command.launcher, command.childArgs, {
|
||||
detached: true,
|
||||
stdio: sidecarLog ? ["ignore", sidecarLog.fd, sidecarLog.fd] : "ignore",
|
||||
env: {
|
||||
...withResolvedClineBuildEnv(process.env),
|
||||
CLINE_NO_INTERACTIVE: "1",
|
||||
},
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
logger.log("Detached hub daemon spawned", {
|
||||
childPid: child.pid,
|
||||
logPath: sidecarLog?.logPath,
|
||||
workspaceRoot,
|
||||
endpoint,
|
||||
});
|
||||
child.unref();
|
||||
} finally {
|
||||
if (sidecarLog) {
|
||||
closeSync(sidecarLog.fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readHubDiscoverySync(): { pid?: number } | undefined {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(owner.discoveryPath, "utf8")) as {
|
||||
pid?: unknown;
|
||||
};
|
||||
return typeof parsed.pid === "number" ? { pid: parsed.pid } : {};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isPidAlive(pid: number | undefined): boolean {
|
||||
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function prewarmCliHubServer(
|
||||
workspaceRoot: string,
|
||||
endpoint: HubEndpointOverrides = {},
|
||||
): void {
|
||||
const discovery = readHubDiscoverySync();
|
||||
if (isPidAlive(discovery?.pid)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
spawnCliHubStartDetached(workspaceRoot, endpoint);
|
||||
} catch {
|
||||
// Best-effort background prewarm only.
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureCliHubServer(
|
||||
workspaceRoot: string,
|
||||
endpoint: HubEndpointOverrides = {},
|
||||
): Promise<string> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const discovered = await readHubDiscovery(owner.discoveryPath);
|
||||
if (discovered?.url) {
|
||||
const healthy = await probeHubServer(discovered.url);
|
||||
if (healthy?.url) {
|
||||
return healthy.url;
|
||||
}
|
||||
}
|
||||
|
||||
spawnCliHubStartDetached(workspaceRoot, endpoint);
|
||||
const deadline = Date.now() + 8_000;
|
||||
while (Date.now() < deadline) {
|
||||
const nextDiscovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (nextDiscovery?.url) {
|
||||
const healthy = await probeHubServer(nextDiscovery.url);
|
||||
if (healthy?.url) {
|
||||
return healthy.url;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
throw new Error("Timed out waiting for background hub startup.");
|
||||
}
|
||||
@@ -3,10 +3,6 @@ import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCliSubcommandCommand,
|
||||
buildInternalCliEnv,
|
||||
CLINE_INTERNAL_DEPTH_ENV,
|
||||
CLINE_INTERNAL_ROLE_ENV,
|
||||
getInternalLaunchViolation,
|
||||
resolveCliLaunchSpec,
|
||||
} from "./internal-launch";
|
||||
|
||||
@@ -42,7 +38,7 @@ describe("internal launch helpers", () => {
|
||||
});
|
||||
|
||||
it("falls back to launching the compiled binary directly for bunfs argv", () => {
|
||||
const command = buildCliSubcommandCommand("hook-worker", [], {
|
||||
const command = buildCliSubcommandCommand("hub", ["start"], {
|
||||
execPath: "/tmp/cline",
|
||||
argv: ["bun", "/$bunfs/root/cline", "hey"],
|
||||
execArgv: [],
|
||||
@@ -51,14 +47,14 @@ describe("internal launch helpers", () => {
|
||||
|
||||
expect(command).toEqual({
|
||||
launcher: "/tmp/cline",
|
||||
childArgs: ["hook-worker"],
|
||||
childArgs: ["hub", "start"],
|
||||
});
|
||||
});
|
||||
|
||||
it("adds node debug flags for development node launches", () => {
|
||||
const utilsDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(utilsDir, "../../../../");
|
||||
const command = buildCliSubcommandCommand("hook-worker", [], {
|
||||
const command = buildCliSubcommandCommand("hub", ["start"], {
|
||||
execPath: "/usr/local/bin/node",
|
||||
argv: ["node", "./apps/cli/src/index.ts"],
|
||||
execArgv: [],
|
||||
@@ -72,28 +68,9 @@ describe("internal launch helpers", () => {
|
||||
"--inspect=127.0.0.1:0",
|
||||
"--enable-source-maps",
|
||||
resolve(repoRoot, "apps/cli/src/index.ts"),
|
||||
"hook-worker",
|
||||
"hub",
|
||||
"start",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects internal launches when the expected subcommand is missing", () => {
|
||||
const env = buildInternalCliEnv("hook-worker", {});
|
||||
|
||||
expect(
|
||||
getInternalLaunchViolation(["/$bunfs/root/cline", "hook-worker"], env),
|
||||
).toContain('expected subcommand "hook-worker"');
|
||||
expect(getInternalLaunchViolation(["hook-worker"], env)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects nested internal launches beyond depth one", () => {
|
||||
const env = {
|
||||
[CLINE_INTERNAL_ROLE_ENV]: "hook-worker",
|
||||
[CLINE_INTERNAL_DEPTH_ENV]: "2",
|
||||
};
|
||||
|
||||
expect(getInternalLaunchViolation(["hook-worker"], env)).toContain(
|
||||
"refusing nested internal CLI launch",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,14 +3,8 @@ import { isAbsolute, resolve as resolvePath } from "node:path";
|
||||
import {
|
||||
augmentNodeCommandForDebug,
|
||||
type ClineDebugRole,
|
||||
withResolvedClineBuildEnv,
|
||||
} from "@clinebot/shared";
|
||||
|
||||
export const CLINE_INTERNAL_ROLE_ENV = "CLINE_INTERNAL_ROLE";
|
||||
export const CLINE_INTERNAL_DEPTH_ENV = "CLINE_INTERNAL_DEPTH";
|
||||
|
||||
export type CliInternalRole = "hook" | "hook-worker";
|
||||
|
||||
export interface ResolveCliLaunchSpecOptions {
|
||||
execPath?: string;
|
||||
argv?: string[];
|
||||
@@ -86,66 +80,11 @@ export function buildCliSubcommandCommand(
|
||||
args: string[] = [],
|
||||
options: ResolveCliLaunchSpecOptions = {},
|
||||
): { launcher: string; childArgs: string[] } | undefined {
|
||||
const debugRole =
|
||||
options.debugRole ??
|
||||
(subcommand === "rpc"
|
||||
? "rpc"
|
||||
: subcommand === "hook" || subcommand === "hook-worker"
|
||||
? "hook-worker"
|
||||
: undefined);
|
||||
const spec = resolveCliLaunchSpec({ ...options, debugRole });
|
||||
if (!spec) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
launcher: spec.launcher,
|
||||
childArgs: [...spec.childArgsPrefix, subcommand, ...args],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInternalCliEnv(
|
||||
role: CliInternalRole,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
const nextEnv = withResolvedClineBuildEnv(env);
|
||||
const currentDepth = Number.parseInt(
|
||||
nextEnv[CLINE_INTERNAL_DEPTH_ENV] ?? "0",
|
||||
10,
|
||||
);
|
||||
const nextDepth = Number.isFinite(currentDepth) ? currentDepth + 1 : 1;
|
||||
return {
|
||||
...nextEnv,
|
||||
[CLINE_INTERNAL_ROLE_ENV]: role,
|
||||
[CLINE_INTERNAL_DEPTH_ENV]: String(nextDepth),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldDisableInternalRuntimeHooks(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
const role = env[CLINE_INTERNAL_ROLE_ENV]?.trim();
|
||||
return role === "hook" || role === "hook-worker";
|
||||
}
|
||||
|
||||
export function getInternalLaunchViolation(
|
||||
cliArgs: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
const expectedRole = env[CLINE_INTERNAL_ROLE_ENV]?.trim();
|
||||
if (!expectedRole) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const depthValue = Number.parseInt(env[CLINE_INTERNAL_DEPTH_ENV] ?? "0", 10);
|
||||
const depth = Number.isFinite(depthValue) ? depthValue : 0;
|
||||
if (depth > 1) {
|
||||
return `refusing nested internal CLI launch for role "${expectedRole}" at depth ${depth}`;
|
||||
}
|
||||
|
||||
const receivedSubcommand = cliArgs[0]?.trim();
|
||||
if (receivedSubcommand !== expectedRole) {
|
||||
return `internal CLI role "${expectedRole}" expected subcommand "${expectedRole}" but received ${receivedSubcommand ? `"${receivedSubcommand}"` : "none"}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
const spec = resolveCliLaunchSpec(options);
|
||||
return spec
|
||||
? {
|
||||
launcher: spec.launcher,
|
||||
childArgs: [...spec.childArgsPrefix, subcommand, ...args],
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { closeSync, mkdirSync, openSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
ensureRpcRuntimeAddress as ensureSharedRpcRuntimeAddress,
|
||||
type ResolveRpcRuntimeResult,
|
||||
RPC_BUILD_ID_ENV,
|
||||
RPC_DISCOVERY_PATH_ENV,
|
||||
RPC_OWNER_ID_ENV,
|
||||
RPC_STARTUP_LOCK_BYPASS_ENV,
|
||||
type RpcOwnerContext,
|
||||
resolveClineDataDir,
|
||||
resolveRpcOwnerContext,
|
||||
tryAcquireRpcSpawnLease,
|
||||
} from "@clinebot/core";
|
||||
import { withResolvedClineBuildEnv } from "@clinebot/shared";
|
||||
import { createCliLoggerAdapter } from "../logging/adapter";
|
||||
import { logSpawnedProcess } from "../logging/process";
|
||||
import {
|
||||
buildCliSubcommandCommand,
|
||||
resolveCliLaunchSpec,
|
||||
} from "./internal-launch";
|
||||
|
||||
function resolveRpcEntrypoint(): string | undefined {
|
||||
return resolveCliLaunchSpec()?.identityPath;
|
||||
}
|
||||
|
||||
export function resolveCurrentCliRpcOwnerContext(): RpcOwnerContext {
|
||||
return resolveRpcOwnerContext({
|
||||
discoveryPath: process.env[RPC_DISCOVERY_PATH_ENV]?.trim(),
|
||||
identityPath: resolveRpcEntrypoint(),
|
||||
ownerId: process.env[RPC_OWNER_ID_ENV]?.trim(),
|
||||
ownerPrefix: "cli",
|
||||
});
|
||||
}
|
||||
|
||||
function getRpcCommandLogger() {
|
||||
return createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
component: "rpc",
|
||||
}).core;
|
||||
}
|
||||
|
||||
function openDetachedRpcLogFile(): { fd: number; logPath: string } | undefined {
|
||||
try {
|
||||
const logPath = join(resolveClineDataDir(), "logs", "rpc-sidecar.log");
|
||||
mkdirSync(dirname(logPath), { recursive: true });
|
||||
return { fd: openSync(logPath, "a"), logPath };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnCliRpcStartDetached(
|
||||
address: string,
|
||||
owner: RpcOwnerContext,
|
||||
): void {
|
||||
const logger = getRpcCommandLogger();
|
||||
const lease = tryAcquireRpcSpawnLease(address);
|
||||
if (!lease) {
|
||||
logger.log("RPC sidecar spawn skipped", {
|
||||
address,
|
||||
reason: "spawn_lease_unavailable",
|
||||
severity: "warn",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const command = buildCliSubcommandCommand("rpc", [
|
||||
"start",
|
||||
"--address",
|
||||
address,
|
||||
]);
|
||||
if (!command) {
|
||||
lease.release();
|
||||
logger.error?.("RPC sidecar spawn aborted", {
|
||||
address,
|
||||
reason: "unable_to_resolve_cli_entrypoint",
|
||||
});
|
||||
throw new Error("unable to resolve CLI entrypoint for detached rpc start");
|
||||
}
|
||||
|
||||
const sidecarLog = openDetachedRpcLogFile();
|
||||
logger.log("Launching detached RPC sidecar", {
|
||||
address,
|
||||
command: [command.launcher, ...command.childArgs].join(" "),
|
||||
commandArgs: command.childArgs,
|
||||
executable: command.launcher,
|
||||
cwd: process.cwd(),
|
||||
logPath: sidecarLog?.logPath,
|
||||
ownerId: owner.ownerId,
|
||||
buildId: owner.buildId,
|
||||
});
|
||||
try {
|
||||
const child = spawn(command.launcher, command.childArgs, {
|
||||
detached: true,
|
||||
stdio: sidecarLog ? ["ignore", sidecarLog.fd, sidecarLog.fd] : "ignore",
|
||||
env: {
|
||||
...withResolvedClineBuildEnv(process.env),
|
||||
[RPC_STARTUP_LOCK_BYPASS_ENV]: "1",
|
||||
[RPC_OWNER_ID_ENV]: owner.ownerId,
|
||||
[RPC_BUILD_ID_ENV]: owner.buildId,
|
||||
[RPC_DISCOVERY_PATH_ENV]: owner.discoveryPath,
|
||||
},
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
logSpawnedProcess({
|
||||
component: "rpc",
|
||||
command: [command.launcher, ...command.childArgs],
|
||||
childPid: child.pid ?? undefined,
|
||||
detached: true,
|
||||
cwd: process.cwd(),
|
||||
metadata: {
|
||||
rpcAddress: address,
|
||||
purpose: "rpc.start.background",
|
||||
logPath: sidecarLog?.logPath,
|
||||
},
|
||||
});
|
||||
logger.log("Detached RPC sidecar spawned", {
|
||||
address,
|
||||
childPid: child.pid,
|
||||
logPath: sidecarLog?.logPath,
|
||||
});
|
||||
child.unref();
|
||||
setTimeout(() => lease.release(), 10_000).unref();
|
||||
} catch (error) {
|
||||
lease.release();
|
||||
logger.error?.("RPC sidecar spawn failed", {
|
||||
address,
|
||||
logPath: sidecarLog?.logPath,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
if (sidecarLog) {
|
||||
closeSync(sidecarLog.fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureCliRpcRuntime(
|
||||
requestedAddress: string,
|
||||
): Promise<ResolveRpcRuntimeResult> {
|
||||
return await ensureSharedRpcRuntimeAddress(requestedAddress, {
|
||||
resolveOwner: resolveCurrentCliRpcOwnerContext,
|
||||
spawnIfNeeded: (address, owner) => {
|
||||
spawnCliRpcStartDetached(address, owner);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureCliRpcRuntimeAddress(
|
||||
requestedAddress: string,
|
||||
): Promise<string> {
|
||||
return (await ensureCliRpcRuntime(requestedAddress)).address;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
CoreSessionConfig,
|
||||
Llms,
|
||||
ProviderSettings,
|
||||
RpcChatRuntimeLoggerConfig,
|
||||
RuntimeLoggerConfig,
|
||||
SessionLineage,
|
||||
SessionManifest,
|
||||
ToolPolicy,
|
||||
@@ -18,16 +18,13 @@ export type CliReasoningEffort = NonNullable<
|
||||
export interface Config extends Omit<CoreSessionConfig, "apiKey" | "mode"> {
|
||||
apiKey: string;
|
||||
knownModels?: Record<string, Llms.ModelInfo>;
|
||||
loggerConfig?: RpcChatRuntimeLoggerConfig;
|
||||
loggerConfig?: RuntimeLoggerConfig;
|
||||
verbose: boolean;
|
||||
timeoutSeconds?: number;
|
||||
sandbox: boolean;
|
||||
sandboxDataDir?: string;
|
||||
thinking: boolean;
|
||||
missionLogIntervalSteps: number;
|
||||
missionLogIntervalMs: number;
|
||||
showUsage: boolean;
|
||||
showTimings: boolean;
|
||||
outputMode: CliOutputMode;
|
||||
mode: CliAgentMode;
|
||||
defaultToolAutoApprove: boolean;
|
||||
@@ -74,7 +71,6 @@ export interface ParsedArgs {
|
||||
verbose: boolean;
|
||||
interactive: boolean;
|
||||
showUsage: boolean;
|
||||
showTimings: boolean;
|
||||
outputMode: CliOutputMode;
|
||||
mode: CliAgentMode;
|
||||
yolo?: boolean;
|
||||
@@ -90,9 +86,6 @@ export interface ParsedArgs {
|
||||
configDir?: string;
|
||||
hooksDir?: string;
|
||||
acpMode: boolean;
|
||||
enableSpawnAgent: boolean;
|
||||
enableAgentTeams: boolean;
|
||||
enableTools: boolean;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
taskId?: string;
|
||||
@@ -101,8 +94,5 @@ export interface ParsedArgs {
|
||||
invalidMaxConsecutiveMistakes?: string;
|
||||
cwd?: string;
|
||||
teamName?: string;
|
||||
missionLogIntervalSteps?: number;
|
||||
missionLogIntervalMs?: number;
|
||||
defaultToolAutoApprove: boolean;
|
||||
toolPolicies: Record<string, ToolPolicy>;
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ export default defineConfig({
|
||||
replacement: resolve(rootDir, "../../packages/core/src/index.ts"),
|
||||
},
|
||||
{
|
||||
find: /^@clinebot\/rpc$/,
|
||||
replacement: resolve(rootDir, "../../packages/rpc/src/index.ts"),
|
||||
find: /^@clinebot\/hub$/,
|
||||
replacement: resolve(rootDir, "../../packages/hub/src/index.ts"),
|
||||
},
|
||||
{
|
||||
find: /^@clinebot\/shared$/,
|
||||
|
||||
@@ -33,7 +33,7 @@ Desktop transport envelope:
|
||||
|
||||
## Settings: Routine
|
||||
|
||||
- The Settings sidebar includes a `Routine` view for scheduler-backed automations.
|
||||
- The Settings sidebar includes a `Routine` view for hub-backed automations.
|
||||
- `Routine` lists all RPC schedules and shows status (`enabled`, `nextRunAt`, active execution).
|
||||
- From the UI you can open a create form and add, pause/resume, trigger-now, and delete schedules.
|
||||
- The view is wired to the same scheduler APIs used by `clite schedule` through Tauri commands and `scripts/routine-schedules.ts`.
|
||||
@@ -43,7 +43,7 @@ Desktop transport envelope:
|
||||
- [`src-tauri/src/main.rs`](./apps/code/src-tauri/src/main.rs) - Tauri shell lifecycle, backend launch, and native-only commands
|
||||
- [`sidecar/index.ts`](./apps/code/sidecar/index.ts) - persistent Bun sidecar backend
|
||||
- [`sidecar/chat-session.ts`](./apps/code/sidecar/chat-session.ts) - in-process chat session runtime
|
||||
- [`scripts/routine-schedules.ts`](./apps/code/scripts/routine-schedules.ts) - RPC scheduler action bridge for Settings > Routine
|
||||
- [`scripts/routine-schedules.ts`](./apps/code/scripts/routine-schedules.ts) - Routine action bridge for Settings > Routine
|
||||
- [`lib/desktop-client.ts`](./apps/code/lib/desktop-client.ts) - typed desktop websocket client
|
||||
- [`hooks/use-chat-session.ts`](./apps/code/hooks/use-chat-session.ts) - UI chat session state + backend subscriptions
|
||||
- [`lib/chat-schema.ts`](./apps/code/lib/chat-schema.ts) - chat message schema used by the UI
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@clinebot/agents": "workspace:*",
|
||||
"@clinebot/core": "workspace:*",
|
||||
"@clinebot/hub": "workspace:*",
|
||||
"@clinebot/llms": "workspace:*",
|
||||
"@clinebot/rpc": "workspace:*",
|
||||
"@clinebot/shared": "workspace:*",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
|
||||
@@ -94,13 +94,14 @@ import { SqliteSessionStore, resolveSessionBackend } from "@clinebot/core";
|
||||
const store = new SqliteSessionStore();
|
||||
```
|
||||
|
||||
### 5. Routine Schedules — Direct RpcSessionClient (kept)
|
||||
### 5. Routine Schedules — Direct Hub Commands
|
||||
|
||||
Scheduler operations still use `RpcSessionClient` since they talk to the scheduler service. But they're called in-process, not via child script:
|
||||
Routine operations now ensure the local hub server in-process and issue hub schedule commands directly. They are still called in-process, not via child script:
|
||||
|
||||
```typescript
|
||||
import { RpcSessionClient } from "@clinebot/rpc";
|
||||
const client = new RpcSessionClient({ address });
|
||||
import { ensureHubServer, sendHubCommand } from "@clinebot/hub";
|
||||
await ensureHubServer({ runtimeHandlers: createLocalHubScheduleRuntimeHandlers() });
|
||||
await sendHubCommand({}, { command: "schedule.list", payload: { limit: 200 } });
|
||||
```
|
||||
|
||||
### 6. Native Commands
|
||||
@@ -143,7 +144,7 @@ Supported commands:
|
||||
| `get_process_context` | In-memory context |
|
||||
| `poll_tool_approvals` | In-memory pending map |
|
||||
| `respond_tool_approval` | In-memory promise resolution |
|
||||
| `list_routine_schedules` | `RpcSessionClient` |
|
||||
| `list_routine_schedules` | local hub schedule commands |
|
||||
| `list_user_instruction_configs` | Direct core API |
|
||||
| `pick_workspace_directory` | OS native dialog |
|
||||
| `open_mcp_settings_file` | OS `open` command |
|
||||
|
||||
@@ -4,7 +4,10 @@ import { basename, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
buildWorkspaceMetadata,
|
||||
type LocalRuntimeHost,
|
||||
type ClineCore,
|
||||
createUserInstructionConfigWatcher,
|
||||
loadRulesForSystemPromptFromWatcher,
|
||||
mergeRulesForSystemPrompt,
|
||||
SessionSource,
|
||||
splitCoreSessionConfig,
|
||||
} from "@clinebot/core";
|
||||
@@ -179,9 +182,20 @@ function createLiveSession(
|
||||
status: overrides?.status ?? "idle",
|
||||
prompt: overrides?.prompt,
|
||||
title: overrides?.title,
|
||||
attachedViaHub: overrides?.attachedViaHub ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function isoTimestampToMs(
|
||||
value: string | null | undefined,
|
||||
): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
return {
|
||||
sessionId: config.sessionId ?? config.session_id,
|
||||
@@ -228,15 +242,30 @@ async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
? "plan"
|
||||
: "act";
|
||||
const metadata = await buildWorkspaceMetadata(cwd);
|
||||
let watcherRules: string | undefined;
|
||||
const watcher = createUserInstructionConfigWatcher({
|
||||
skills: { workspacePath: cwd },
|
||||
rules: { workspacePath: cwd },
|
||||
workflows: { workspacePath: cwd },
|
||||
});
|
||||
try {
|
||||
await watcher.start();
|
||||
watcherRules = loadRulesForSystemPromptFromWatcher(watcher);
|
||||
} catch {
|
||||
watcherRules = undefined;
|
||||
} finally {
|
||||
watcher.stop();
|
||||
}
|
||||
const inlineRules =
|
||||
typeof config.rules === "string" && config.rules.trim().length > 0
|
||||
? config.rules
|
||||
: undefined;
|
||||
return buildClineSystemPrompt({
|
||||
ide: "Terminal Shell",
|
||||
workspaceRoot: cwd,
|
||||
workspaceName: basename(cwd),
|
||||
metadata,
|
||||
rules:
|
||||
typeof config.rules === "string" && config.rules.trim().length > 0
|
||||
? config.rules
|
||||
: undefined,
|
||||
rules: mergeRulesForSystemPrompt(watcherRules, inlineRules),
|
||||
mode,
|
||||
providerId: providerId || undefined,
|
||||
overridePrompt:
|
||||
@@ -272,7 +301,7 @@ function sendPromptsInQueueSnapshot(
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionManager(ctx: SidecarContext): LocalRuntimeHost {
|
||||
function getSessionManager(ctx: SidecarContext): ClineCore {
|
||||
if (!ctx.sessionManager) throw new Error("Session manager not initialized");
|
||||
return ctx.sessionManager;
|
||||
}
|
||||
@@ -316,6 +345,76 @@ async function handleStart(
|
||||
return { sessionId };
|
||||
}
|
||||
|
||||
async function handleAttach(
|
||||
ctx: SidecarContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) {
|
||||
throw new Error("sessionId is required");
|
||||
}
|
||||
|
||||
const manager = getSessionManager(ctx);
|
||||
const session = await manager.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
const metadata =
|
||||
session.metadata && typeof session.metadata === "object"
|
||||
? (session.metadata as JsonRecord)
|
||||
: undefined;
|
||||
const existing = ctx.liveSessions.get(sessionId);
|
||||
if (ctx.hubClient) {
|
||||
await ctx.hubClient.command("session.attach", { sessionId }, sessionId);
|
||||
}
|
||||
const attachedConfig: JsonRecord = {
|
||||
...(existing?.config ?? {}),
|
||||
...(request.config ?? {}),
|
||||
sessionId,
|
||||
provider: session.provider || existing?.config.provider || "",
|
||||
model: session.model || existing?.config.model || "",
|
||||
cwd:
|
||||
session.cwd ||
|
||||
session.workspaceRoot ||
|
||||
String(request.config?.cwd ?? "").trim() ||
|
||||
String(existing?.config.cwd ?? "").trim(),
|
||||
workspaceRoot:
|
||||
session.workspaceRoot ||
|
||||
session.cwd ||
|
||||
String(request.config?.workspaceRoot ?? "").trim() ||
|
||||
String(existing?.config.workspaceRoot ?? "").trim(),
|
||||
};
|
||||
ctx.liveSessions.set(
|
||||
sessionId,
|
||||
createLiveSession(attachedConfig, {
|
||||
messages: existing?.messages ?? [],
|
||||
promptsInQueue: existing?.promptsInQueue ?? [],
|
||||
status: session.status,
|
||||
prompt:
|
||||
session.prompt ||
|
||||
(typeof metadata?.prompt === "string" ? metadata.prompt : undefined) ||
|
||||
existing?.prompt,
|
||||
title:
|
||||
(typeof metadata?.title === "string" ? metadata.title : undefined) ||
|
||||
existing?.title,
|
||||
endedAt: isoTimestampToMs(session.endedAt),
|
||||
attachedViaHub: true,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
status: session.status,
|
||||
provider: session.provider,
|
||||
model: session.model,
|
||||
cwd: session.cwd,
|
||||
workspaceRoot: session.workspaceRoot,
|
||||
prompt: session.prompt,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSend(
|
||||
ctx: SidecarContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
@@ -598,6 +697,7 @@ const ACTION_HANDLERS: Record<
|
||||
(ctx: SidecarContext, req: ChatSessionCommandRequest) => Promise<unknown>
|
||||
> = {
|
||||
start: handleStart,
|
||||
attach: handleAttach,
|
||||
send: handleSend,
|
||||
stop: handleStop,
|
||||
abort: handleAbort,
|
||||
|
||||
@@ -10,32 +10,39 @@ import {
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import type {
|
||||
RpcClineAccountActionRequest,
|
||||
RpcProviderCapability,
|
||||
ClineAccountActionRequest,
|
||||
ProviderCapability,
|
||||
} from "@clinebot/core";
|
||||
import {
|
||||
ALL_DEFAULT_TOOL_NAMES,
|
||||
addLocalProvider,
|
||||
ClineAccountService,
|
||||
ClineCore,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
createUserInstructionConfigWatcher,
|
||||
discoverPluginModulePaths,
|
||||
ensureCustomProvidersLoaded,
|
||||
executeRpcClineAccountAction,
|
||||
executeClineAccountAction,
|
||||
getLocalProviderModels,
|
||||
listHookConfigFiles,
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
loginLocalProvider,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
resolveLocalClineAuthToken,
|
||||
resolvePluginConfigSearchPaths,
|
||||
resolveRulesConfigSearchPaths,
|
||||
resolveSessionBackend,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
resolveSkillsConfigSearchPaths,
|
||||
resolveWorkflowsConfigSearchPaths,
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
toggleDisabledTool,
|
||||
} from "@clinebot/core";
|
||||
import { RpcSessionClient } from "@clinebot/rpc";
|
||||
import { ensureHubServer, sendHubCommand } from "@clinebot/hub";
|
||||
import { broadcastEvent } from "./context";
|
||||
import {
|
||||
findArtifactUnderDir,
|
||||
@@ -67,10 +74,57 @@ function readMcpServersResponse(): JsonRecord {
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
|
||||
const servers = parsed.mcpServers as JsonRecord | undefined;
|
||||
const entries = Object.entries(servers ?? {}).map(([name, body]) => ({
|
||||
name,
|
||||
...(body as JsonRecord),
|
||||
}));
|
||||
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
|
||||
const record = body as JsonRecord;
|
||||
const transport =
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
: undefined;
|
||||
const transportType = String(
|
||||
transport?.type ?? record.transportType ?? record.type ?? "stdio",
|
||||
).trim();
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
disabled: record.disabled === true,
|
||||
command:
|
||||
typeof transport?.command === "string"
|
||||
? transport.command
|
||||
: typeof record.command === "string"
|
||||
? record.command
|
||||
: undefined,
|
||||
args: Array.isArray(transport?.args)
|
||||
? transport.args
|
||||
: Array.isArray(record.args)
|
||||
? record.args
|
||||
: undefined,
|
||||
cwd:
|
||||
typeof transport?.cwd === "string"
|
||||
? transport.cwd
|
||||
: typeof record.cwd === "string"
|
||||
? record.cwd
|
||||
: undefined,
|
||||
env:
|
||||
transport?.env && typeof transport.env === "object"
|
||||
? transport.env
|
||||
: record.env && typeof record.env === "object"
|
||||
? record.env
|
||||
: undefined,
|
||||
url:
|
||||
typeof transport?.url === "string"
|
||||
? transport.url
|
||||
: typeof record.url === "string"
|
||||
? record.url
|
||||
: undefined,
|
||||
headers:
|
||||
transport?.headers && typeof transport.headers === "object"
|
||||
? transport.headers
|
||||
: record.headers && typeof record.headers === "object"
|
||||
? record.headers
|
||||
: undefined,
|
||||
metadata: record.metadata,
|
||||
};
|
||||
});
|
||||
return { settingsPath, hasSettingsFile: true, servers: entries };
|
||||
}
|
||||
|
||||
@@ -102,6 +156,29 @@ function removePathIfExists(
|
||||
return true;
|
||||
}
|
||||
|
||||
async function listSessionsFromSidecarManager(
|
||||
ctx: SidecarContext,
|
||||
limit: number,
|
||||
): Promise<unknown> {
|
||||
if (ctx.sessionManager) {
|
||||
return await ctx.sessionManager.list(limit);
|
||||
}
|
||||
const core = await ClineCore.create({
|
||||
backendMode: "hub",
|
||||
hub: {
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
clientType: "code-sidecar-list",
|
||||
displayName: "Code App history",
|
||||
},
|
||||
});
|
||||
try {
|
||||
return await core.list(limit);
|
||||
} finally {
|
||||
await core.dispose("code_sidecar_list_sessions");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -143,7 +220,7 @@ function listGitBranches(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routine schedule helpers (in-process via RpcSessionClient)
|
||||
// Routine schedule helpers (in-process via shared hub server)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toPositiveInt(value: unknown): number | undefined {
|
||||
@@ -162,18 +239,42 @@ async function handleRoutineScheduleCommand(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const address = process.env.CLINE_RPC_ADDRESS?.trim() || "127.0.0.1:4317";
|
||||
const client = new RpcSessionClient({ address });
|
||||
await ensureHubServer({
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
const clientCommand = async (
|
||||
hubCommand: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => {
|
||||
const reply = await sendHubCommand(
|
||||
{},
|
||||
{
|
||||
clientId: "code-sidecar-routines",
|
||||
command: hubCommand as never,
|
||||
payload,
|
||||
},
|
||||
);
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? `hub command failed: ${hubCommand}`,
|
||||
);
|
||||
}
|
||||
return (reply.payload ?? {}) as Record<string, unknown>;
|
||||
};
|
||||
try {
|
||||
if (command === "list_routine_schedules") {
|
||||
const [schedules, activeExecutions, upcomingRuns] = await Promise.all([
|
||||
client.listSchedules({
|
||||
clientCommand("schedule.list", {
|
||||
limit: toPositiveInt(args?.limit) ?? 200,
|
||||
}),
|
||||
client.getActiveScheduledExecutions(),
|
||||
client.getUpcomingScheduledRuns(30),
|
||||
clientCommand("schedule.active"),
|
||||
clientCommand("schedule.upcoming", { limit: 30 }),
|
||||
]);
|
||||
return { schedules, activeExecutions, upcomingRuns };
|
||||
return {
|
||||
schedules: schedules.schedules ?? [],
|
||||
activeExecutions: activeExecutions.executions ?? [],
|
||||
upcomingRuns: upcomingRuns.runs ?? [],
|
||||
};
|
||||
}
|
||||
if (command === "create_routine_schedule") {
|
||||
const name = asTrimmedString(args?.name);
|
||||
@@ -185,12 +286,14 @@ async function handleRoutineScheduleCommand(
|
||||
"createSchedule requires name, cron_pattern, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const created = await client.createSchedule({
|
||||
const created = await clientCommand("schedule.create", {
|
||||
name,
|
||||
cronPattern,
|
||||
prompt,
|
||||
provider: asTrimmedString(args?.provider) ?? "cline",
|
||||
model: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
|
||||
modelSelection: {
|
||||
providerId: asTrimmedString(args?.provider) ?? "cline",
|
||||
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
|
||||
},
|
||||
mode: args?.mode === "plan" ? "plan" : "act",
|
||||
workspaceRoot,
|
||||
cwd: asTrimmedString(args?.cwd),
|
||||
@@ -206,29 +309,28 @@ async function handleRoutineScheduleCommand(
|
||||
.filter((v: string) => v.length > 0)
|
||||
: undefined,
|
||||
});
|
||||
return { schedule: created ?? null };
|
||||
return { schedule: created.schedule ?? null };
|
||||
}
|
||||
const scheduleId = asTrimmedString(args?.schedule_id);
|
||||
if (!scheduleId) throw new Error(`${command} requires schedule_id`);
|
||||
if (command === "pause_routine_schedule") {
|
||||
const schedule = await client.pauseSchedule(scheduleId);
|
||||
return { schedule: schedule ?? null };
|
||||
const reply = await clientCommand("schedule.disable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "resume_routine_schedule") {
|
||||
const schedule = await client.resumeSchedule(scheduleId);
|
||||
return { schedule: schedule ?? null };
|
||||
const reply = await clientCommand("schedule.enable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "trigger_routine_schedule") {
|
||||
const execution = await client.triggerScheduleNow(scheduleId);
|
||||
return { execution: execution ?? null };
|
||||
const reply = await clientCommand("schedule.trigger", { scheduleId });
|
||||
return { execution: reply.execution ?? null };
|
||||
}
|
||||
if (command === "delete_routine_schedule") {
|
||||
const deleted = await client.deleteSchedule(scheduleId);
|
||||
return { deleted };
|
||||
const reply = await clientCommand("schedule.delete", { scheduleId });
|
||||
return { deleted: reply.deleted === true };
|
||||
}
|
||||
throw new Error(`unsupported routine schedule command: ${command}`);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,13 +338,8 @@ async function handleRoutineScheduleCommand(
|
||||
// User instruction config listing (in-process via @clinebot/core watchers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveAgentConfigSearchPaths(): string[] {
|
||||
const clineDataDir =
|
||||
process.env.CLINE_DATA_DIR?.trim() || join(homedir(), ".cline", "data");
|
||||
return [
|
||||
join(homedir(), "Documents", "Cline", "Agents"),
|
||||
join(clineDataDir, "settings", "agents"),
|
||||
];
|
||||
function resolveAgentConfigSearchPaths(workspaceRoot?: string): string[] {
|
||||
return resolveSharedAgentConfigSearchPaths(workspaceRoot);
|
||||
}
|
||||
|
||||
async function listUserInstructionConfigs(
|
||||
@@ -301,8 +398,8 @@ async function listUserInstructionConfigs(
|
||||
|
||||
const loadAgents = (): unknown[] => {
|
||||
const agentsById = new Map<string, { name: string; path: string }>();
|
||||
const directories = resolveAgentConfigSearchPaths().filter((d) =>
|
||||
existsSync(d),
|
||||
const directories = resolveAgentConfigSearchPaths(workspaceRoot).filter(
|
||||
(d) => existsSync(d),
|
||||
);
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
@@ -344,7 +441,32 @@ async function listUserInstructionConfigs(
|
||||
}
|
||||
};
|
||||
|
||||
const [rules, workflows, skills] = await Promise.all([
|
||||
const loadPlugins = (): Array<{ name: string; path: string }> => {
|
||||
const pluginsByPath = new Map<string, { name: string; path: string }>();
|
||||
const directories = resolvePluginConfigSearchPaths(workspaceRoot).filter(
|
||||
(d) => existsSync(d),
|
||||
);
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
for (const filePath of discoverPluginModulePaths(directory)) {
|
||||
if (pluginsByPath.has(filePath)) {
|
||||
continue;
|
||||
}
|
||||
pluginsByPath.set(filePath, {
|
||||
name: basename(filePath, extname(filePath)),
|
||||
path: filePath,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
return [...pluginsByPath.values()].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
};
|
||||
|
||||
const [rules, workflows, skills, pluginTools] = await Promise.all([
|
||||
loadWatcherSnapshot("rule", resolveRulesConfigSearchPaths(workspaceRoot)),
|
||||
loadWatcherSnapshot(
|
||||
"workflow",
|
||||
@@ -354,6 +476,10 @@ async function listUserInstructionConfigs(
|
||||
...resolveSkillsConfigSearchPaths(workspaceRoot),
|
||||
join(homedir(), "Documents", "Cline", "Skills"),
|
||||
]),
|
||||
listPluginTools({
|
||||
workspacePath: workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -362,7 +488,26 @@ async function listUserInstructionConfigs(
|
||||
workflows,
|
||||
skills,
|
||||
agents: loadAgents(),
|
||||
plugins: loadPlugins(),
|
||||
tools: [
|
||||
...ALL_DEFAULT_TOOL_NAMES.map((name) => ({
|
||||
id: name,
|
||||
name,
|
||||
enabled: true,
|
||||
source: "builtin",
|
||||
})),
|
||||
...pluginTools.map((tool) => ({
|
||||
id: `${tool.pluginName}:${tool.name}:${tool.path}`,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
enabled: tool.enabled,
|
||||
source: tool.source,
|
||||
path: tool.path,
|
||||
pluginName: tool.pluginName,
|
||||
})),
|
||||
],
|
||||
hooks: loadHooks(),
|
||||
mcp: readMcpServersResponse(),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -469,13 +614,17 @@ export async function handleCommand(
|
||||
throw new Error("sessionId and requestId are required");
|
||||
}
|
||||
const pending = ctx.pendingApprovals.get(requestId);
|
||||
if (pending) {
|
||||
pending.resolve({
|
||||
if (pending && ctx.hubClient) {
|
||||
await ctx.hubClient.command("approval.respond", {
|
||||
approvalId: pending.approvalId,
|
||||
approved: Boolean(args?.approved),
|
||||
reason: typeof args?.reason === "string" ? args.reason : undefined,
|
||||
payload:
|
||||
typeof args?.reason === "string" && args.reason.trim().length > 0
|
||||
? { reason: args.reason }
|
||||
: undefined,
|
||||
});
|
||||
ctx.pendingApprovals.delete(requestId);
|
||||
}
|
||||
ctx.pendingApprovals.delete(requestId);
|
||||
const remaining = Array.from(ctx.pendingApprovals.values())
|
||||
.filter((a) => a.item.sessionId === sessionId)
|
||||
.map((a) => a.item);
|
||||
@@ -494,24 +643,16 @@ export async function handleCommand(
|
||||
);
|
||||
}
|
||||
if (command === "list_cli_sessions") {
|
||||
const core = await ClineCore.create();
|
||||
try {
|
||||
return await core.list(
|
||||
typeof args?.limit === "number" ? args.limit : 300,
|
||||
);
|
||||
} finally {
|
||||
await core.dispose("code_sidecar_list_cli_sessions");
|
||||
}
|
||||
return await listSessionsFromSidecarManager(
|
||||
ctx,
|
||||
typeof args?.limit === "number" ? args.limit : 300,
|
||||
);
|
||||
}
|
||||
if (command === "list_discovered_sessions") {
|
||||
const core = await ClineCore.create();
|
||||
try {
|
||||
return await core.list(
|
||||
typeof args?.limit === "number" ? args.limit : 300,
|
||||
);
|
||||
} finally {
|
||||
await core.dispose("code_sidecar_list_discovered_sessions");
|
||||
}
|
||||
return await listSessionsFromSidecarManager(
|
||||
ctx,
|
||||
typeof args?.limit === "number" ? args.limit : 300,
|
||||
);
|
||||
}
|
||||
if (command === "update_chat_session_title") {
|
||||
const sessionId = String(args?.sessionId ?? "").trim();
|
||||
@@ -640,8 +781,8 @@ export async function handleCommand(
|
||||
apiBaseUrl: settings?.baseUrl?.trim() || "https://api.cline.bot",
|
||||
getAuthToken: async () => resolveLocalClineAuthToken(settings),
|
||||
});
|
||||
return await executeRpcClineAccountAction(
|
||||
args as RpcClineAccountActionRequest,
|
||||
return await executeClineAccountAction(
|
||||
args as ClineAccountActionRequest,
|
||||
accountService,
|
||||
);
|
||||
}
|
||||
@@ -694,7 +835,7 @@ export async function handleCommand(
|
||||
? args.models_source_url
|
||||
: undefined,
|
||||
capabilities: Array.isArray(args?.capabilities)
|
||||
? (args.capabilities as RpcProviderCapability[])
|
||||
? (args.capabilities as ProviderCapability[])
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
@@ -761,6 +902,9 @@ export async function handleCommand(
|
||||
: (args as JsonRecord);
|
||||
const name = String(input.name ?? "").trim();
|
||||
if (!name) throw new Error("server name is required");
|
||||
const previousName = String(
|
||||
input.previousName ?? input.previous_name ?? "",
|
||||
).trim();
|
||||
const transportType = String(
|
||||
input.transportType ?? input.transport_type ?? "",
|
||||
).trim();
|
||||
@@ -789,6 +933,9 @@ export async function handleCommand(
|
||||
const path = ensureMcpSettingsFile();
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
|
||||
const servers = (parsed.mcpServers as JsonRecord | undefined) ?? {};
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = next;
|
||||
writeMcpServersMap(servers);
|
||||
return readMcpServersResponse();
|
||||
@@ -847,6 +994,14 @@ export async function handleCommand(
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
}
|
||||
if (command === "toggle_disabled_plugin_tool") {
|
||||
const toolName = String(args?.name ?? "").trim();
|
||||
if (!toolName) {
|
||||
throw new Error("tool name is required");
|
||||
}
|
||||
toggleDisabledTool(toolName);
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
}
|
||||
|
||||
// ── Native OS commands ────────────────────────────────────────────
|
||||
if (command === "pick_workspace_directory") {
|
||||
|
||||
@@ -2,11 +2,9 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname } from "node:path";
|
||||
import {
|
||||
ClineCore,
|
||||
type CoreSessionEvent,
|
||||
CoreSessionService,
|
||||
LocalRuntimeHost,
|
||||
ProviderSettingsManager,
|
||||
SqliteSessionStore,
|
||||
NodeHubClient,
|
||||
setHomeDirIfUnset,
|
||||
} from "@clinebot/core";
|
||||
import type { AgentEvent } from "@clinebot/shared";
|
||||
@@ -56,7 +54,15 @@ function emitChunk(
|
||||
): void {
|
||||
const ts = nowMs();
|
||||
appendSessionChunk(sessionId, stream, chunk, ts);
|
||||
sendEvent(ctx, "chat_event", { sessionId, stream, chunk, ts });
|
||||
const nextIndex = (ctx.streamIndices.get(sessionId) ?? 0) + 1;
|
||||
ctx.streamIndices.set(sessionId, nextIndex);
|
||||
sendEvent(ctx, "chat_event", {
|
||||
sessionId,
|
||||
stream,
|
||||
chunk,
|
||||
ts,
|
||||
index: nextIndex,
|
||||
});
|
||||
}
|
||||
|
||||
export { sendEvent, emitChunk, nowMs };
|
||||
@@ -366,64 +372,199 @@ function handleCoreSessionEvent(
|
||||
export function createSidecarContext(workspaceRoot: string): SidecarContext {
|
||||
return {
|
||||
liveSessions: new Map(),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
pendingApprovals: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
workspaceRoot,
|
||||
unsubscribeSessionEvents: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function handleHubApprovalEvent(
|
||||
ctx: SidecarContext,
|
||||
event: {
|
||||
event: string;
|
||||
sessionId?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
},
|
||||
): void {
|
||||
if (event.event !== "approval.requested") {
|
||||
return;
|
||||
}
|
||||
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
|
||||
const approvalId =
|
||||
typeof event.payload?.approvalId === "string"
|
||||
? event.payload.approvalId.trim()
|
||||
: "";
|
||||
if (!sessionId || !approvalId) {
|
||||
return;
|
||||
}
|
||||
const requestId = approvalId;
|
||||
ctx.pendingApprovals.set(requestId, {
|
||||
approvalId,
|
||||
item: {
|
||||
requestId,
|
||||
sessionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
toolCallId:
|
||||
typeof event.payload?.toolCallId === "string"
|
||||
? event.payload.toolCallId
|
||||
: "",
|
||||
toolName:
|
||||
typeof event.payload?.toolName === "string"
|
||||
? event.payload.toolName
|
||||
: "tool",
|
||||
input:
|
||||
typeof event.payload?.inputJson === "string"
|
||||
? event.payload.inputJson
|
||||
: undefined,
|
||||
conversationId: sessionId,
|
||||
},
|
||||
});
|
||||
const sessionApprovals = Array.from(ctx.pendingApprovals.values())
|
||||
.filter((approval) => approval.item.sessionId === sessionId)
|
||||
.map((approval) => approval.item);
|
||||
sendEvent(ctx, "tool_approval_state", {
|
||||
sessionId,
|
||||
items: sessionApprovals,
|
||||
});
|
||||
}
|
||||
|
||||
export function handleHubLiveEvent(
|
||||
ctx: SidecarContext,
|
||||
event: {
|
||||
event: string;
|
||||
sessionId?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
},
|
||||
): void {
|
||||
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (!session?.attachedViaHub) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.event) {
|
||||
case "assistant.delta": {
|
||||
const text =
|
||||
typeof event.payload?.text === "string" ? event.payload.text : "";
|
||||
if (text) {
|
||||
emitChunk(ctx, sessionId, "chat_text", text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "reasoning.delta": {
|
||||
const text =
|
||||
typeof event.payload?.text === "string" ? event.payload.text : "";
|
||||
const redacted = event.payload?.redacted === true;
|
||||
if (!text && !redacted) {
|
||||
return;
|
||||
}
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_reasoning",
|
||||
JSON.stringify({ text, redacted }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "tool.started": {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_tool_call_start",
|
||||
JSON.stringify({
|
||||
toolCallId:
|
||||
typeof event.payload?.toolCallId === "string"
|
||||
? event.payload.toolCallId
|
||||
: undefined,
|
||||
toolName:
|
||||
typeof event.payload?.toolName === "string"
|
||||
? event.payload.toolName
|
||||
: "tool",
|
||||
input: event.payload?.input,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "tool.finished": {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_tool_call_end",
|
||||
JSON.stringify({
|
||||
toolCallId:
|
||||
typeof event.payload?.toolCallId === "string"
|
||||
? event.payload.toolCallId
|
||||
: undefined,
|
||||
toolName:
|
||||
typeof event.payload?.toolName === "string"
|
||||
? event.payload.toolName
|
||||
: "tool",
|
||||
output: event.payload?.output,
|
||||
error:
|
||||
typeof event.payload?.error === "string"
|
||||
? event.payload.error
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "run.started":
|
||||
case "session.attached":
|
||||
case "session.updated": {
|
||||
const payloadSession =
|
||||
event.payload?.session &&
|
||||
typeof event.payload.session === "object" &&
|
||||
!Array.isArray(event.payload.session)
|
||||
? (event.payload.session as Record<string, unknown>)
|
||||
: undefined;
|
||||
const status =
|
||||
typeof payloadSession?.status === "string"
|
||||
? payloadSession.status
|
||||
: event.event === "run.started"
|
||||
? "running"
|
||||
: session.status;
|
||||
session.status = status;
|
||||
session.busy = status === "running";
|
||||
sendEvent(ctx, "chat_session_status", { sessionId, status });
|
||||
return;
|
||||
}
|
||||
case "run.completed":
|
||||
case "run.aborted": {
|
||||
const reason =
|
||||
typeof event.payload?.reason === "string"
|
||||
? event.payload.reason
|
||||
: event.event === "run.aborted"
|
||||
? "aborted"
|
||||
: "completed";
|
||||
session.status = reason;
|
||||
session.busy = false;
|
||||
session.endedAt = nowMs();
|
||||
sendEvent(ctx, "chat_session_ended", { sessionId, reason });
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeSessionManager(
|
||||
ctx: SidecarContext,
|
||||
): Promise<void> {
|
||||
setHomeDirIfUnset(homedir());
|
||||
|
||||
const store = new SqliteSessionStore();
|
||||
const sessionService = new CoreSessionService(store);
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
|
||||
const sessionManager = new LocalRuntimeHost({
|
||||
sessionService,
|
||||
providerSettingsManager,
|
||||
defaultToolExecutors: {
|
||||
askQuestion: async (_question, options) => options[0] ?? "",
|
||||
submit: async (summary, verified) => {
|
||||
const status = verified ? "verified" : "unverified";
|
||||
return `Submission recorded (${status}): ${summary}`;
|
||||
},
|
||||
},
|
||||
requestToolApproval: async (request) => {
|
||||
const requestId = `${request.conversationId}_${request.toolCallId}`;
|
||||
const item = {
|
||||
requestId,
|
||||
sessionId: request.conversationId,
|
||||
createdAt: new Date().toISOString(),
|
||||
toolCallId: request.toolCallId,
|
||||
toolName: request.toolName,
|
||||
input: request.input,
|
||||
iteration: request.iteration,
|
||||
agentId: request.agentId,
|
||||
conversationId: request.conversationId,
|
||||
};
|
||||
|
||||
return new Promise<{ approved: boolean; reason?: string }>((resolve) => {
|
||||
ctx.pendingApprovals.set(requestId, {
|
||||
request,
|
||||
resolve,
|
||||
item,
|
||||
});
|
||||
|
||||
// Broadcast approval snapshot so the frontend renders the dialog
|
||||
const sessionApprovals = Array.from(ctx.pendingApprovals.values())
|
||||
.filter((a) => a.item.sessionId === request.conversationId)
|
||||
.map((a) => a.item);
|
||||
|
||||
sendEvent(ctx, "tool_approval_state", {
|
||||
sessionId: request.conversationId,
|
||||
items: sessionApprovals,
|
||||
});
|
||||
});
|
||||
const sessionManager = await ClineCore.create({
|
||||
backendMode: "hub",
|
||||
hub: {
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -432,6 +573,24 @@ export async function initializeSessionManager(
|
||||
handleCoreSessionEvent(ctx, event);
|
||||
});
|
||||
|
||||
const runtimeAddress = sessionManager.runtimeAddress?.trim();
|
||||
let hubClient: NodeHubClient | null = null;
|
||||
if (runtimeAddress) {
|
||||
hubClient = new NodeHubClient({
|
||||
url: runtimeAddress,
|
||||
clientType: "code-sidecar-approvals",
|
||||
displayName: "Code App approvals",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
await hubClient.connect();
|
||||
hubClient.subscribe((event) => {
|
||||
handleHubApprovalEvent(ctx, event);
|
||||
handleHubLiveEvent(ctx, event);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.sessionManager = sessionManager;
|
||||
ctx.hubClient = hubClient;
|
||||
ctx.unsubscribeSessionEvents = unsubscribe;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ToolApprovalRequest, ToolApprovalResult } from "@clinebot/shared";
|
||||
import type { ClineCore, NodeHubClient } from "@clinebot/core";
|
||||
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ChatTurnAttachments = {
|
||||
export type ChatSessionCommandRequest = {
|
||||
action:
|
||||
| "start"
|
||||
| "attach"
|
||||
| "send"
|
||||
| "stop"
|
||||
| "abort"
|
||||
@@ -43,6 +44,7 @@ export type LiveSession = {
|
||||
status: string;
|
||||
prompt?: string;
|
||||
title?: string;
|
||||
attachedViaHub?: boolean;
|
||||
};
|
||||
|
||||
export type ToolApprovalRequestItem = {
|
||||
@@ -58,16 +60,17 @@ export type ToolApprovalRequestItem = {
|
||||
};
|
||||
|
||||
export type PendingToolApproval = {
|
||||
request: ToolApprovalRequest;
|
||||
resolve: (result: ToolApprovalResult) => void;
|
||||
approvalId: string;
|
||||
item: ToolApprovalRequestItem;
|
||||
};
|
||||
|
||||
export type SidecarContext = {
|
||||
liveSessions: Map<string, LiveSession>;
|
||||
streamIndices: Map<string, number>;
|
||||
wsClients: Set<any>;
|
||||
pendingApprovals: Map<string, PendingToolApproval>;
|
||||
sessionManager: import("@clinebot/core").LocalRuntimeHost | null;
|
||||
sessionManager: ClineCore | null;
|
||||
hubClient: NodeHubClient | null;
|
||||
workspaceRoot: string;
|
||||
unsubscribeSessionEvents: (() => void) | null;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@clinebot/core": ["../../packages/core/src/index.ts"],
|
||||
"@clinebot/rpc": ["../../packages/rpc/src/index.ts"],
|
||||
"@clinebot/hub": ["../../packages/hub/src/index.ts"],
|
||||
"@clinebot/shared": ["../../packages/shared/src/index.ts"],
|
||||
"@clinebot/shared/storage": [
|
||||
"../../packages/shared/src/storage/index.ts"
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
"@/*": ["./*"],
|
||||
"@clinebot/agents": ["../../packages/agents/src/index.ts"],
|
||||
"@clinebot/core": ["../../packages/core/src/index.ts"],
|
||||
"@clinebot/hub": ["../../packages/hub/src/index.ts"],
|
||||
"@clinebot/llms": ["../../packages/llms/src/index.ts"],
|
||||
"@clinebot/rpc": ["../../packages/rpc/src/index.ts"],
|
||||
"@clinebot/shared": ["../../packages/shared/src/index.ts"],
|
||||
"@clinebot/shared/storage": [
|
||||
"../../packages/shared/src/storage/index.ts"
|
||||
|
||||
@@ -202,6 +202,7 @@ export default function Home() {
|
||||
threadId={activeThread.id}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onNewThread={handleNewThread}
|
||||
onOpenSession={handleOpenSession}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -223,6 +224,7 @@ function ChatThreadPane({
|
||||
onUpdateSessionMetadata,
|
||||
onDeleteSession,
|
||||
onNewThread,
|
||||
onOpenSession,
|
||||
}: {
|
||||
threadId: string;
|
||||
historySession?: SessionHistoryItem;
|
||||
@@ -232,6 +234,7 @@ function ChatThreadPane({
|
||||
) => void;
|
||||
onDeleteSession?: (sessionId: string, threadId?: string) => void;
|
||||
onNewThread?: () => void;
|
||||
onOpenSession?: (session: SessionHistoryItem) => void;
|
||||
}) {
|
||||
const {
|
||||
sessionId,
|
||||
@@ -252,6 +255,7 @@ function ChatThreadPane({
|
||||
approveToolApproval,
|
||||
rejectToolApproval,
|
||||
restoreCheckpoint,
|
||||
forkSession,
|
||||
reset,
|
||||
abort,
|
||||
hydrateSession,
|
||||
@@ -598,6 +602,29 @@ function ChatThreadPane({
|
||||
[rejectToolApproval],
|
||||
);
|
||||
|
||||
const handleForkSession = useCallback(async () => {
|
||||
const result = await forkSession();
|
||||
// Open the forked session as a new thread in the sidebar.
|
||||
if (onOpenSession) {
|
||||
const forkedHistorySession: SessionHistoryItem = {
|
||||
sessionId: result.newSessionId,
|
||||
status: "completed",
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
startedAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
fork: {
|
||||
forkedFromSessionId: result.forkedFromSessionId,
|
||||
forkedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
};
|
||||
onOpenSession(forkedHistorySession);
|
||||
}
|
||||
}, [config, forkSession, onOpenSession]);
|
||||
|
||||
const visibleHistorySession =
|
||||
historySession?.sessionId &&
|
||||
historySession.sessionId === dismissedHistorySessionId
|
||||
@@ -846,6 +873,7 @@ function ChatThreadPane({
|
||||
onRestoreCheckpoint={(runCount) =>
|
||||
void restoreCheckpoint(runCount)
|
||||
}
|
||||
onForkSession={handleForkSession}
|
||||
pendingToolApprovals={pendingToolApprovals}
|
||||
provider={config.provider}
|
||||
sessionId={displayedSessionId}
|
||||
|
||||
@@ -86,6 +86,16 @@ type SessionDeletedEvent = CustomEvent<{
|
||||
sessionId: string;
|
||||
}>;
|
||||
|
||||
type SidecarSessionStateEvent = {
|
||||
sessionId?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type SidecarChatEvent = {
|
||||
sessionId?: string;
|
||||
stream?: string;
|
||||
};
|
||||
|
||||
const filterOptions = ["All", "Running", "Recent", "Pinned"] as const;
|
||||
type FilterOption = (typeof filterOptions)[number];
|
||||
const INITIAL_HISTORY_FETCH_LIMIT = 300;
|
||||
@@ -464,6 +474,7 @@ export function AgentSidebar({
|
||||
);
|
||||
const sessionsRef = useRef<SessionHistoryItem[]>([]);
|
||||
const threadsRef = useRef<Thread[]>([]);
|
||||
const refreshTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions;
|
||||
@@ -562,12 +573,25 @@ export function AgentSidebar({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleRefresh = useCallback(
|
||||
(delayMs = 0) => {
|
||||
if (refreshTimeoutRef.current !== null) {
|
||||
window.clearTimeout(refreshTimeoutRef.current);
|
||||
}
|
||||
refreshTimeoutRef.current = window.setTimeout(() => {
|
||||
refreshTimeoutRef.current = null;
|
||||
void refreshSessions();
|
||||
}, delayMs);
|
||||
},
|
||||
[refreshSessions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
const runRefresh = () => {
|
||||
if (!disposed) {
|
||||
void refreshSessions();
|
||||
scheduleRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -582,8 +606,12 @@ export function AgentSidebar({
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(interval);
|
||||
if (refreshTimeoutRef.current !== null) {
|
||||
window.clearTimeout(refreshTimeoutRef.current);
|
||||
refreshTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [refreshSessions]);
|
||||
}, [scheduleRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const recent = sessions.slice(0, 24);
|
||||
@@ -723,7 +751,7 @@ export function AgentSidebar({
|
||||
setThreads((current) =>
|
||||
current.filter((thread) => thread.id !== sessionId),
|
||||
);
|
||||
void refreshSessions();
|
||||
scheduleRefresh(50);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
@@ -754,6 +782,61 @@ export function AgentSidebar({
|
||||
);
|
||||
},
|
||||
);
|
||||
const unsubscribeTransportStatus = desktopClient.subscribe(
|
||||
"chat_session_status",
|
||||
(payload) => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return;
|
||||
}
|
||||
const record = payload as SidecarSessionStateEvent;
|
||||
const sessionId = record.sessionId?.trim();
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
const known = sessionsRef.current.some(
|
||||
(session) => session.sessionId === sessionId,
|
||||
);
|
||||
if (
|
||||
!known ||
|
||||
record.status === "running" ||
|
||||
record.status === "starting" ||
|
||||
record.status === "idle"
|
||||
) {
|
||||
scheduleRefresh(50);
|
||||
}
|
||||
},
|
||||
);
|
||||
const unsubscribeTransportEnded = desktopClient.subscribe(
|
||||
"chat_session_ended",
|
||||
(payload) => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return;
|
||||
}
|
||||
const record = payload as SidecarSessionStateEvent;
|
||||
if (record.sessionId?.trim()) {
|
||||
scheduleRefresh(50);
|
||||
}
|
||||
},
|
||||
);
|
||||
const unsubscribeTransportChatEvent = desktopClient.subscribe(
|
||||
"chat_event",
|
||||
(payload) => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return;
|
||||
}
|
||||
const record = payload as SidecarChatEvent;
|
||||
const sessionId = record.sessionId?.trim();
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
const known = sessionsRef.current.some(
|
||||
(session) => session.sessionId === sessionId,
|
||||
);
|
||||
if (!known) {
|
||||
scheduleRefresh(50);
|
||||
}
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"cline:session-title-updated",
|
||||
@@ -764,8 +847,11 @@ export function AgentSidebar({
|
||||
handleSessionDeleted as EventListener,
|
||||
);
|
||||
unsubscribeTransportDelete();
|
||||
unsubscribeTransportStatus();
|
||||
unsubscribeTransportEnded();
|
||||
unsubscribeTransportChatEvent();
|
||||
};
|
||||
}, [refreshSessions]);
|
||||
}, [scheduleRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const recent = sessions.slice(0, 24);
|
||||
|
||||
@@ -13,7 +13,7 @@ function Switch({
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer cursor-pointer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer cursor-pointer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-foreground/20 shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -21,7 +21,7 @@ function Switch({
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
"bg-foreground/50 dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
|
||||
@@ -42,6 +42,24 @@ type ActiveMention = {
|
||||
query: string;
|
||||
};
|
||||
|
||||
type ActiveSlash = {
|
||||
slashIndex: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
type SlashCommand = {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
{
|
||||
name: "fork",
|
||||
description: "Create a copy of the current session into a new session",
|
||||
},
|
||||
{ name: "team", description: "Start the task with an agent team" },
|
||||
];
|
||||
|
||||
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
|
||||
cline: ["anthropic/claude-sonnet-4.6"],
|
||||
anthropic: ["claude-sonnet-4-6"],
|
||||
@@ -95,6 +113,34 @@ function getActiveMention(input: string, cursor: number): ActiveMention | null {
|
||||
};
|
||||
}
|
||||
|
||||
function getActiveSlash(input: string, cursor: number): ActiveSlash | null {
|
||||
if (cursor < 0 || cursor > input.length) {
|
||||
return null;
|
||||
}
|
||||
const left = input.slice(0, cursor);
|
||||
const slashIndex = left.lastIndexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
// Slash must be at the start or preceded by whitespace.
|
||||
if (slashIndex > 0 && !/\s/.test(left[slashIndex - 1] ?? "")) {
|
||||
return null;
|
||||
}
|
||||
const query = left.slice(slashIndex + 1);
|
||||
// No whitespace allowed inside the query — once the user typed a space
|
||||
// the slash command has been committed.
|
||||
if (/\s/.test(query)) {
|
||||
return null;
|
||||
}
|
||||
// Don't open slash mode if there's already a completed slash command earlier in the input.
|
||||
const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.-]+\s/;
|
||||
const textBeforeCurrentSlash = input.slice(0, slashIndex);
|
||||
if (firstSlashCommandRegex.test(textBeforeCurrentSlash)) {
|
||||
return null;
|
||||
}
|
||||
return { slashIndex, query };
|
||||
}
|
||||
|
||||
type ChatInputBarProps = {
|
||||
status: ChatSessionStatus;
|
||||
provider: string;
|
||||
@@ -177,6 +223,17 @@ export function ChatInputBar({
|
||||
const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0);
|
||||
const mentionResultsCacheRef = useRef(new Map<string, string[]>());
|
||||
const mentionLastRequestKeyRef = useRef<string | null>(null);
|
||||
|
||||
// ---- Slash command state ----
|
||||
const [slashOpen, setSlashOpen] = useState(false);
|
||||
const [activeSlash, setActiveSlash] = useState<ActiveSlash | null>(null);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>(
|
||||
BUILTIN_SLASH_COMMANDS,
|
||||
);
|
||||
const [slashLoading, setSlashLoading] = useState(false);
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
||||
const slashCommandsLoadedRef = useRef(false);
|
||||
|
||||
const tokensSummary = useMemo(() => {
|
||||
const total = summary.tokensIn + summary.tokensOut;
|
||||
if (total === 0) {
|
||||
@@ -302,6 +359,100 @@ export function ChatInputBar({
|
||||
[activeMention, onPromptInputChange, promptInput],
|
||||
);
|
||||
|
||||
// ---- Slash command effects ----
|
||||
|
||||
// Detect slash mode from current input + cursor position.
|
||||
useEffect(() => {
|
||||
const nextSlash = getActiveSlash(promptInput, cursorIndex);
|
||||
setActiveSlash(nextSlash);
|
||||
setSlashOpen(nextSlash !== null);
|
||||
}, [promptInput, cursorIndex]);
|
||||
|
||||
// Reset selection index when slash menu opens/closes.
|
||||
useEffect(() => {
|
||||
if (!slashOpen) {
|
||||
setSlashSelectedIndex(0);
|
||||
}
|
||||
}, [slashOpen]);
|
||||
|
||||
// Lazily load workflow commands from the sidecar the first time the slash
|
||||
// menu opens, then merge with the built-in commands.
|
||||
useEffect(() => {
|
||||
if (!slashOpen || slashCommandsLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
slashCommandsLoadedRef.current = true;
|
||||
let cancelled = false;
|
||||
setSlashLoading(true);
|
||||
desktopClient
|
||||
.invoke<{
|
||||
workflows?: Array<{ id: string; name: string }>;
|
||||
}>("list_user_instruction_configs")
|
||||
.then((response: { workflows?: Array<{ id: string; name: string }> }) => {
|
||||
if (cancelled) return;
|
||||
const workflows = Array.isArray(response?.workflows)
|
||||
? response.workflows
|
||||
: [];
|
||||
const workflowCommands: SlashCommand[] = workflows.map(
|
||||
(w: { id: string; name: string }) => ({
|
||||
name: w.name.toLowerCase().replace(/\s+/g, "-"),
|
||||
description: "Workflow command",
|
||||
}),
|
||||
);
|
||||
const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((c) => c.name));
|
||||
const dedupedWorkflows = workflowCommands.filter(
|
||||
(c) => !builtinNames.has(c.name),
|
||||
);
|
||||
setSlashCommands([...BUILTIN_SLASH_COMMANDS, ...dedupedWorkflows]);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep built-in commands on error.
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSlashLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slashOpen]);
|
||||
|
||||
// Filtered slash commands based on the current query.
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
if (!slashOpen) return [];
|
||||
const query = (activeSlash?.query ?? "").trim().toLowerCase();
|
||||
if (!query) {
|
||||
return slashCommands.slice(0, 10);
|
||||
}
|
||||
return slashCommands
|
||||
.filter((cmd) => cmd.name.toLowerCase().includes(query))
|
||||
.sort((a, b) => {
|
||||
const aStarts = a.name.toLowerCase().startsWith(query);
|
||||
const bStarts = b.name.toLowerCase().startsWith(query);
|
||||
if (aStarts && !bStarts) return -1;
|
||||
if (!aStarts && bStarts) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.slice(0, 10);
|
||||
}, [slashOpen, activeSlash?.query, slashCommands]);
|
||||
|
||||
const insertSlashCommandItem = useCallback(
|
||||
(commandName: string) => {
|
||||
if (!activeSlash) return;
|
||||
const nextValue = `${promptInput.slice(0, activeSlash.slashIndex)}/${commandName} `;
|
||||
onPromptInputChange(nextValue);
|
||||
setSlashOpen(false);
|
||||
const nextCursor = activeSlash.slashIndex + commandName.length + 2;
|
||||
requestAnimationFrame(() => {
|
||||
const input = promptInputRef.current;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.setSelectionRange(nextCursor, nextCursor);
|
||||
setCursorIndex(nextCursor);
|
||||
});
|
||||
},
|
||||
[activeSlash, onPromptInputChange, promptInput],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-card">
|
||||
{/* Input area */}
|
||||
@@ -359,6 +510,45 @@ export function ChatInputBar({
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
{slashOpen && (
|
||||
<div className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl">
|
||||
{filteredSlashCommands.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{slashLoading
|
||||
? "Loading commands..."
|
||||
: "No matching commands"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredSlashCommands.map((cmd, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full flex-col rounded-md px-3 py-2 text-left text-xs transition-colors",
|
||||
index === slashSelectedIndex
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
key={cmd.name}
|
||||
onClick={() => insertSlashCommandItem(cmd.name)}
|
||||
type="button"
|
||||
>
|
||||
<span className="font-medium">/{cmd.name}</span>
|
||||
{cmd.description && (
|
||||
<span className="text-[10px] opacity-70">
|
||||
{cmd.description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{slashLoading && (
|
||||
<div className="px-3 py-1 text-[10px] text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{mentionOpen && (
|
||||
<div className="absolute inset-x-0 bottom-full z-50 mb-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-xl">
|
||||
{mentionFiles.length === 0 ? (
|
||||
@@ -406,6 +596,38 @@ export function ChatInputBar({
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
// Slash command menu takes priority when open.
|
||||
if (slashOpen && filteredSlashCommands.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSlashSelectedIndex(
|
||||
(prev) => (prev + 1) % filteredSlashCommands.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSlashSelectedIndex(
|
||||
(prev) =>
|
||||
(prev - 1 + filteredSlashCommands.length) %
|
||||
filteredSlashCommands.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const selected = filteredSlashCommands[slashSelectedIndex];
|
||||
if (selected) {
|
||||
insertSlashCommandItem(selected.name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (slashOpen && e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setSlashOpen(false);
|
||||
return;
|
||||
}
|
||||
if (mentionOpen && mentionFiles.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Copy,
|
||||
FileEdit,
|
||||
FileSearch,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Search,
|
||||
@@ -45,6 +46,7 @@ type ChatMessagesProps = {
|
||||
onApproveToolApproval: (requestId: string) => void | Promise<void>;
|
||||
onRejectToolApproval: (requestId: string) => void | Promise<void>;
|
||||
onRestoreCheckpoint?: (runCount: number) => void | Promise<void>;
|
||||
onForkSession?: () => void | Promise<void>;
|
||||
onStartChat?: (prompt: string) => void;
|
||||
};
|
||||
|
||||
@@ -78,6 +80,7 @@ function ChatMessagesImpl({
|
||||
onApproveToolApproval,
|
||||
onRejectToolApproval,
|
||||
onRestoreCheckpoint,
|
||||
onForkSession,
|
||||
onStartChat,
|
||||
}: ChatMessagesProps) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -103,6 +106,8 @@ function ChatMessagesImpl({
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
||||
const [forkingMessageId, setForkingMessageId] = useState<string | null>(null);
|
||||
const [forkErrors, setForkErrors] = useState<Record<string, string>>({});
|
||||
const showIdleDetails =
|
||||
!hasMessages && !isSessionSwitching && !showSwitchTransition;
|
||||
|
||||
@@ -267,6 +272,35 @@ function ChatMessagesImpl({
|
||||
[onRestoreCheckpoint],
|
||||
);
|
||||
|
||||
const handleForkSession = useCallback(
|
||||
async (messageId: string) => {
|
||||
if (!onForkSession) {
|
||||
return;
|
||||
}
|
||||
setForkingMessageId(messageId);
|
||||
setForkErrors((prev) => {
|
||||
if (!prev[messageId]) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[messageId];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await Promise.resolve(onForkSession());
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Could not fork session.";
|
||||
setForkErrors((prev) => ({ ...prev, [messageId]: message }));
|
||||
} finally {
|
||||
setForkingMessageId((current) =>
|
||||
current === messageId ? null : current,
|
||||
);
|
||||
}
|
||||
},
|
||||
[onForkSession],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 min-w-0">
|
||||
<div
|
||||
@@ -325,6 +359,13 @@ function ChatMessagesImpl({
|
||||
restoreError={checkpointErrors[message.id]}
|
||||
restorePending={checkpointActions[message.id] === "undoing"}
|
||||
wasCopied={copiedMessageId === message.id}
|
||||
onForkSession={
|
||||
onForkSession
|
||||
? () => void handleForkSession(message.id)
|
||||
: undefined
|
||||
}
|
||||
forkPending={forkingMessageId === message.id}
|
||||
forkError={forkErrors[message.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -514,6 +555,9 @@ function MessageBubble({
|
||||
restorePending = false,
|
||||
restoreError,
|
||||
wasCopied = false,
|
||||
onForkSession,
|
||||
forkPending = false,
|
||||
forkError,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
isStreaming?: boolean;
|
||||
@@ -523,6 +567,9 @@ function MessageBubble({
|
||||
restorePending?: boolean;
|
||||
restoreError?: string;
|
||||
wasCopied?: boolean;
|
||||
onForkSession?: () => void;
|
||||
forkPending?: boolean;
|
||||
forkError?: string;
|
||||
}) {
|
||||
const isUser = message.role === "user";
|
||||
const isError = message.role === "error";
|
||||
@@ -610,6 +657,31 @@ function MessageBubble({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!isUser &&
|
||||
!isError &&
|
||||
message.role === "assistant" &&
|
||||
onForkSession ? (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<Button
|
||||
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
disabled={forkPending}
|
||||
onClick={onForkSession}
|
||||
size="sm"
|
||||
title="Fork session — copy full message history into a new session"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{forkPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<GitBranch className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">{forkError}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
RpcClineAccountBalance,
|
||||
RpcClineAccountOrganization,
|
||||
RpcClineAccountOrganizationBalance,
|
||||
RpcClineAccountOrganizationUsageTransaction,
|
||||
RpcClineAccountPaymentTransaction,
|
||||
RpcClineAccountUsageTransaction,
|
||||
RpcClineAccountUser,
|
||||
} from "@clinebot/shared";
|
||||
ClineAccountBalance,
|
||||
ClineAccountOrganization,
|
||||
ClineAccountOrganizationBalance,
|
||||
ClineAccountOrganizationUsageTransaction,
|
||||
ClineAccountPaymentTransaction,
|
||||
ClineAccountUsageTransaction,
|
||||
ClineAccountUser,
|
||||
} from "@clinebot/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
Building,
|
||||
@@ -30,24 +30,24 @@ import { cn } from "@/lib/utils";
|
||||
// Data fetching helpers via sidecar command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchAccountUser(): Promise<RpcClineAccountUser> {
|
||||
return await desktopClient.invoke<RpcClineAccountUser>("cline_account", {
|
||||
async function fetchAccountUser(): Promise<ClineAccountUser> {
|
||||
return await desktopClient.invoke<ClineAccountUser>("cline_account", {
|
||||
action: "clineAccount",
|
||||
operation: "fetchMe",
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAccountBalance(): Promise<RpcClineAccountBalance> {
|
||||
return await desktopClient.invoke<RpcClineAccountBalance>("cline_account", {
|
||||
async function fetchAccountBalance(): Promise<ClineAccountBalance> {
|
||||
return await desktopClient.invoke<ClineAccountBalance>("cline_account", {
|
||||
action: "clineAccount",
|
||||
operation: "fetchBalance",
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAccountOrganizations(): Promise<
|
||||
RpcClineAccountOrganization[]
|
||||
ClineAccountOrganization[]
|
||||
> {
|
||||
return await desktopClient.invoke<RpcClineAccountOrganization[]>(
|
||||
return await desktopClient.invoke<ClineAccountOrganization[]>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
@@ -58,8 +58,8 @@ async function fetchAccountOrganizations(): Promise<
|
||||
|
||||
async function fetchOrganizationBalance(
|
||||
organizationId: string,
|
||||
): Promise<RpcClineAccountOrganizationBalance> {
|
||||
return await desktopClient.invoke<RpcClineAccountOrganizationBalance>(
|
||||
): Promise<ClineAccountOrganizationBalance> {
|
||||
return await desktopClient.invoke<ClineAccountOrganizationBalance>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
@@ -70,9 +70,9 @@ async function fetchOrganizationBalance(
|
||||
}
|
||||
|
||||
async function fetchUsageTransactions(): Promise<
|
||||
RpcClineAccountUsageTransaction[]
|
||||
ClineAccountUsageTransaction[]
|
||||
> {
|
||||
return await desktopClient.invoke<RpcClineAccountUsageTransaction[]>(
|
||||
return await desktopClient.invoke<ClineAccountUsageTransaction[]>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
@@ -84,21 +84,22 @@ async function fetchUsageTransactions(): Promise<
|
||||
async function fetchOrganizationUsageTransactions(
|
||||
organizationId: string,
|
||||
memberId?: string,
|
||||
): Promise<RpcClineAccountOrganizationUsageTransaction[]> {
|
||||
return await desktopClient.invoke<
|
||||
RpcClineAccountOrganizationUsageTransaction[]
|
||||
>("cline_account", {
|
||||
action: "clineAccount",
|
||||
operation: "fetchOrganizationUsageTransactions",
|
||||
organizationId,
|
||||
...(memberId?.trim() ? { memberId: memberId.trim() } : {}),
|
||||
});
|
||||
): Promise<ClineAccountOrganizationUsageTransaction[]> {
|
||||
return await desktopClient.invoke<ClineAccountOrganizationUsageTransaction[]>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
operation: "fetchOrganizationUsageTransactions",
|
||||
organizationId,
|
||||
...(memberId?.trim() ? { memberId: memberId.trim() } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchPaymentTransactions(): Promise<
|
||||
RpcClineAccountPaymentTransaction[]
|
||||
ClineAccountPaymentTransaction[]
|
||||
> {
|
||||
return await desktopClient.invoke<RpcClineAccountPaymentTransaction[]>(
|
||||
return await desktopClient.invoke<ClineAccountPaymentTransaction[]>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
@@ -117,19 +118,19 @@ export function AccountView() {
|
||||
);
|
||||
|
||||
// Overview data
|
||||
const [user, setUser] = useState<RpcClineAccountUser | null>(null);
|
||||
const [balance, setBalance] = useState<RpcClineAccountBalance | null>(null);
|
||||
const [user, setUser] = useState<ClineAccountUser | null>(null);
|
||||
const [balance, setBalance] = useState<ClineAccountBalance | null>(null);
|
||||
const [organizationBalance, setOrganizationBalance] =
|
||||
useState<RpcClineAccountOrganizationBalance | null>(null);
|
||||
useState<ClineAccountOrganizationBalance | null>(null);
|
||||
const [organizations, setOrganizations] = useState<
|
||||
RpcClineAccountOrganization[]
|
||||
ClineAccountOrganization[]
|
||||
>([]);
|
||||
const [overviewLoading, setOverviewLoading] = useState(true);
|
||||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||||
|
||||
// Usage data
|
||||
const [usageTransactions, setUsageTransactions] = useState<
|
||||
RpcClineAccountUsageTransaction[]
|
||||
ClineAccountUsageTransaction[]
|
||||
>([]);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [usageError, setUsageError] = useState<string | null>(null);
|
||||
@@ -138,7 +139,7 @@ export function AccountView() {
|
||||
|
||||
// Billing data
|
||||
const [paymentTransactions, setPaymentTransactions] = useState<
|
||||
RpcClineAccountPaymentTransaction[]
|
||||
ClineAccountPaymentTransaction[]
|
||||
>([]);
|
||||
const [billingLoading, setBillingLoading] = useState(false);
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
|
||||
+453
-52
@@ -6,17 +6,26 @@ import {
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Play,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
TriangleAlert,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ShortcutTab = "Rules" | "Workflows" | "Hooks" | "Skills" | "Agents";
|
||||
type ShortcutTab =
|
||||
| "Rules"
|
||||
| "Hooks"
|
||||
| "Skills"
|
||||
| "Agents"
|
||||
| "Plugins"
|
||||
| "Tools";
|
||||
|
||||
type RuleItem = {
|
||||
name: string;
|
||||
@@ -38,11 +47,36 @@ type SkillItem = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
type CommandItem = {
|
||||
id: string;
|
||||
type: "workflow" | "skill";
|
||||
name: string;
|
||||
description?: string;
|
||||
instructions: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type AgentItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type PluginItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type ToolItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
source: string;
|
||||
path?: string;
|
||||
pluginName?: string;
|
||||
headlessToolNames?: string[];
|
||||
};
|
||||
|
||||
type HookItem = {
|
||||
fileName: string;
|
||||
hookEventName?: string;
|
||||
@@ -64,13 +98,35 @@ type HookExecutionSummary = {
|
||||
lastTs: string | null;
|
||||
};
|
||||
|
||||
type McpServer = {
|
||||
name: string;
|
||||
transportType: "stdio" | "sse" | "streamableHttp";
|
||||
disabled: boolean;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
metadata?: unknown;
|
||||
};
|
||||
|
||||
type McpServersResponse = {
|
||||
settingsPath: string;
|
||||
hasSettingsFile: boolean;
|
||||
servers: McpServer[];
|
||||
};
|
||||
|
||||
type UserInstructionListsResponse = {
|
||||
workspaceRoot: string;
|
||||
rules: RuleItem[];
|
||||
workflows: WorkflowItem[];
|
||||
skills: SkillItem[];
|
||||
agents: AgentItem[];
|
||||
plugins: PluginItem[];
|
||||
tools: ToolItem[];
|
||||
hooks: HookItem[];
|
||||
mcp: McpServersResponse;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
@@ -141,6 +197,12 @@ export function RulesView() {
|
||||
const [agents, setAgents] = useState<AgentItem[]>(
|
||||
() => extensionListsCache?.agents ?? [],
|
||||
);
|
||||
const [plugins, setPlugins] = useState<PluginItem[]>(
|
||||
() => extensionListsCache?.plugins ?? [],
|
||||
);
|
||||
const [tools, setTools] = useState<ToolItem[]>(
|
||||
() => extensionListsCache?.tools ?? [],
|
||||
);
|
||||
const [hooks, setHooks] = useState<HookItem[]>(
|
||||
() => extensionListsCache?.hooks ?? [],
|
||||
);
|
||||
@@ -154,6 +216,9 @@ export function RulesView() {
|
||||
string | null
|
||||
>(() => extensionHookStatsCache?.hookExecutionSessionId ?? null);
|
||||
const [hookExecutionLoading, setHookExecutionLoading] = useState(false);
|
||||
const [togglingToolIds, setTogglingToolIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const refresh = useCallback(async (force = false) => {
|
||||
const now = Date.now();
|
||||
@@ -167,6 +232,8 @@ export function RulesView() {
|
||||
setWorkflows(extensionListsCache.workflows);
|
||||
setSkills(extensionListsCache.skills);
|
||||
setAgents(extensionListsCache.agents);
|
||||
setPlugins(extensionListsCache.plugins);
|
||||
setTools(extensionListsCache.tools);
|
||||
setHooks(extensionListsCache.hooks);
|
||||
setWarnings(extensionListsCache.warnings);
|
||||
setErrorMessage(null);
|
||||
@@ -183,6 +250,8 @@ export function RulesView() {
|
||||
setWorkflows(response.workflows);
|
||||
setSkills(response.skills);
|
||||
setAgents(response.agents);
|
||||
setPlugins(response.plugins);
|
||||
setTools(response.tools);
|
||||
setHooks(response.hooks);
|
||||
setWarnings(response.warnings);
|
||||
extensionListsCache = {
|
||||
@@ -275,6 +344,58 @@ export function RulesView() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyResponse = useCallback(
|
||||
(response: UserInstructionListsResponse) => {
|
||||
setWorkspaceRoot(response.workspaceRoot);
|
||||
setRules(response.rules);
|
||||
setWorkflows(response.workflows);
|
||||
setSkills(response.skills);
|
||||
setAgents(response.agents);
|
||||
setPlugins(response.plugins);
|
||||
setTools(response.tools);
|
||||
setHooks(response.hooks);
|
||||
setWarnings(response.warnings);
|
||||
extensionListsCache = {
|
||||
...response,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const togglePluginTool = useCallback(
|
||||
async (tool: ToolItem) => {
|
||||
if (
|
||||
tool.source !== "workspace-plugin" &&
|
||||
tool.source !== "global-plugin"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setTogglingToolIds((current) => new Set(current).add(tool.id));
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response =
|
||||
await desktopClient.invoke<UserInstructionListsResponse>(
|
||||
"toggle_disabled_plugin_tool",
|
||||
{
|
||||
name: tool.name,
|
||||
},
|
||||
);
|
||||
applyResponse(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setTogglingToolIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(tool.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[applyResponse],
|
||||
);
|
||||
|
||||
const formatExecutionTs = useCallback((value: string | null): string => {
|
||||
if (!value) {
|
||||
return "never";
|
||||
@@ -302,12 +423,34 @@ export function RulesView() {
|
||||
|
||||
const tabs: ShortcutTab[] = [
|
||||
"Rules",
|
||||
"Workflows",
|
||||
"Hooks",
|
||||
"Skills",
|
||||
"Agents",
|
||||
"Plugins",
|
||||
"Tools",
|
||||
];
|
||||
|
||||
const commandItems = useMemo<CommandItem[]>(() => {
|
||||
const workflowItems: CommandItem[] = workflows.map((workflow) => ({
|
||||
id: workflow.id,
|
||||
type: "workflow",
|
||||
name: workflow.name,
|
||||
instructions: workflow.instructions,
|
||||
path: workflow.path,
|
||||
}));
|
||||
const skillItems: CommandItem[] = skills.map((skill) => ({
|
||||
id: normalizePath(skill.path).toLowerCase(),
|
||||
type: "skill",
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
instructions: skill.instructions,
|
||||
path: skill.path,
|
||||
}));
|
||||
return [...workflowItems, ...skillItems].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
}, [skills, workflows]);
|
||||
|
||||
const { projectRules, globalRules } = useMemo(() => {
|
||||
const normalizedRoot = normalizePath(workspaceRoot);
|
||||
const project: RuleItem[] = [];
|
||||
@@ -346,6 +489,47 @@ export function RulesView() {
|
||||
return { projectHooks: project, globalHooks: global };
|
||||
}, [hooks, workspaceRoot]);
|
||||
|
||||
const { projectPlugins, globalPlugins } = useMemo(() => {
|
||||
const normalizedRoot = normalizePath(workspaceRoot);
|
||||
const project: PluginItem[] = [];
|
||||
const global: PluginItem[] = [];
|
||||
for (const plugin of plugins) {
|
||||
const normalized = normalizePath(plugin.path);
|
||||
if (
|
||||
normalizedRoot &&
|
||||
normalized.startsWith(`${normalizedRoot}/`) &&
|
||||
normalized.includes("/.cline/plugins")
|
||||
) {
|
||||
project.push(plugin);
|
||||
} else {
|
||||
global.push(plugin);
|
||||
}
|
||||
}
|
||||
return { projectPlugins: project, globalPlugins: global };
|
||||
}, [plugins, workspaceRoot]);
|
||||
|
||||
const builtinTools = useMemo(
|
||||
() => tools.filter((tool) => tool.source === "builtin"),
|
||||
[tools],
|
||||
);
|
||||
const pluginTools = useMemo(
|
||||
() => tools.filter((tool) => tool.source !== "builtin"),
|
||||
[tools],
|
||||
);
|
||||
const pluginToolsByPluginKey = useMemo(() => {
|
||||
const grouped = new Map<string, ToolItem[]>();
|
||||
for (const tool of pluginTools) {
|
||||
const key = `${tool.pluginName ?? ""}:${tool.path ?? ""}`;
|
||||
const existing = grouped.get(key) ?? [];
|
||||
existing.push(tool);
|
||||
grouped.set(key, existing);
|
||||
}
|
||||
for (const items of grouped.values()) {
|
||||
items.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
return grouped;
|
||||
}, [pluginTools]);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
@@ -481,45 +665,6 @@ export function RulesView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "Workflows" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Enabled workflows. Invoke one in chat with{" "}
|
||||
<code className="rounded bg-secondary px-1.5 py-0.5 text-xs font-mono text-foreground">
|
||||
/workflow-name
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{workflows.map((workflow) => (
|
||||
<div
|
||||
key={workflow.path}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Play className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{workflow.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-2 ml-7 text-xs text-muted-foreground">
|
||||
{previewText(workflow.instructions)}
|
||||
</p>
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
{workflow.path}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{workflows.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
No enabled workflows found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "Hooks" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
@@ -677,34 +822,44 @@ export function RulesView() {
|
||||
{activeTab === "Skills" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Enabled skills discovered from workspace and global skill
|
||||
directories.
|
||||
Enabled skills and workflows. Workflows can be invoked in chat
|
||||
with{" "}
|
||||
<code className="rounded bg-secondary px-1.5 py-0.5 text-xs font-mono text-foreground">
|
||||
/workflow-name
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{skills.map((skill) => (
|
||||
{commandItems.map((item) => (
|
||||
<div
|
||||
key={skill.path}
|
||||
key={`${item.type}:${item.path}`}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="h-4 w-4 shrink-0 text-primary" />
|
||||
{item.type === "workflow" ? (
|
||||
<Play className="h-4 w-4 shrink-0 text-primary" />
|
||||
) : (
|
||||
<Zap className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{skill.name}
|
||||
{item.name}
|
||||
</h3>
|
||||
<span className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{item.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 ml-7 text-xs text-muted-foreground">
|
||||
{skill.description?.trim() ||
|
||||
previewText(skill.instructions)}
|
||||
{item.description?.trim() || previewText(item.instructions)}
|
||||
</p>
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
{skill.path}
|
||||
{item.path}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{skills.length === 0 && (
|
||||
{commandItems.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
No enabled skills found.
|
||||
No enabled skills or workflows found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -743,6 +898,252 @@ export function RulesView() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "Plugins" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Plugins discovered from workspace and global plugin directories.
|
||||
</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Global Plugins
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
{globalPlugins.map((plugin) => (
|
||||
<div
|
||||
key={plugin.path}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
{plugin.path}
|
||||
</p>
|
||||
<div className="mt-3 ml-7 flex flex-col gap-2">
|
||||
{(
|
||||
pluginToolsByPluginKey.get(
|
||||
`${plugin.name}:${plugin.path}`,
|
||||
) ?? []
|
||||
).map((tool) => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{tool.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tool.description?.trim() ||
|
||||
"No description available."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => {
|
||||
void togglePluginTool(tool);
|
||||
}}
|
||||
disabled={isToggling}
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(pluginToolsByPluginKey.get(
|
||||
`${plugin.name}:${plugin.path}`,
|
||||
)?.length ?? 0) === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No plugin tools found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{globalPlugins.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
No global plugins found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Project Plugins
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
{projectPlugins.map((plugin) => (
|
||||
<div
|
||||
key={plugin.path}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
{plugin.path}
|
||||
</p>
|
||||
<div className="mt-3 ml-7 flex flex-col gap-2">
|
||||
{(
|
||||
pluginToolsByPluginKey.get(
|
||||
`${plugin.name}:${plugin.path}`,
|
||||
) ?? []
|
||||
).map((tool) => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{tool.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tool.description?.trim() ||
|
||||
"No description available."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => {
|
||||
void togglePluginTool(tool);
|
||||
}}
|
||||
disabled={isToggling}
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(pluginToolsByPluginKey.get(
|
||||
`${plugin.name}:${plugin.path}`,
|
||||
)?.length ?? 0) === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No plugin tools found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{projectPlugins.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
No project plugins found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "Tools" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Builtin tool groups and plugin-contributed tools available to the
|
||||
runtime.
|
||||
</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Builtin Tools
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
{builtinTools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Wrench className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{tool.name}
|
||||
</h3>
|
||||
<span className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{tool.enabled
|
||||
? "enabled by default"
|
||||
: "disabled by default"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 ml-7 text-xs text-muted-foreground">
|
||||
{tool.description?.trim() || "No description available."}
|
||||
</p>
|
||||
{!!tool.headlessToolNames?.length && (
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
{tool.headlessToolNames.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{builtinTools.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
No builtin tools found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Plugin Tools
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
{pluginTools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Wrench className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{tool.name}
|
||||
</h3>
|
||||
{tool.pluginName && (
|
||||
<span className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
plugin: {tool.pluginName}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{tool.enabled ? "enabled" : "disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 ml-7 text-xs text-muted-foreground">
|
||||
{tool.description?.trim() || "No description available."}
|
||||
</p>
|
||||
{tool.path && (
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
{tool.path}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{pluginTools.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
No plugin tools found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
@@ -1,16 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Circle, Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Circle, Minus, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type McpTransportType = "stdio" | "sse" | "streamableHttp";
|
||||
|
||||
interface McpServer {
|
||||
name: string;
|
||||
transportType: "stdio" | "sse" | "streamableHttp";
|
||||
transportType: McpTransportType;
|
||||
disabled: boolean;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
@@ -29,7 +59,8 @@ interface McpServersResponse {
|
||||
|
||||
interface McpServerUpsertInput {
|
||||
name: string;
|
||||
transportType: "stdio" | "sse" | "streamableHttp";
|
||||
previousName?: string;
|
||||
transportType: McpTransportType;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
@@ -40,6 +71,20 @@ interface McpServerUpsertInput {
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
type McpServerFormState = {
|
||||
name: string;
|
||||
previousName: string;
|
||||
transportType: McpTransportType;
|
||||
command: string;
|
||||
argsText: string;
|
||||
cwd: string;
|
||||
envEntries: Array<{ id: string; key: string; value: string }>;
|
||||
url: string;
|
||||
headersText: string;
|
||||
disabled: boolean;
|
||||
metadataText: string;
|
||||
};
|
||||
|
||||
function splitCsv(text: string): string[] {
|
||||
return text
|
||||
.split(",")
|
||||
@@ -77,102 +122,46 @@ function stringifyKeyValuePairs(input?: Record<string, string>): string {
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function getServerInput(existing?: McpServer): McpServerUpsertInput | null {
|
||||
const nameInput = window.prompt("MCP server name", existing?.name ?? "");
|
||||
if (nameInput == null) {
|
||||
return null;
|
||||
}
|
||||
const name = nameInput.trim();
|
||||
if (!name) {
|
||||
window.alert("Server name is required.");
|
||||
return null;
|
||||
function stringifyRedactedKeyValuePairs(
|
||||
input?: Record<string, string>,
|
||||
): string {
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
return Object.keys(input)
|
||||
.map((key) => `${key}=[REDACTED]`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
const transportInput = window.prompt(
|
||||
'Transport type ("stdio", "sse", or "streamableHttp")',
|
||||
existing?.transportType ?? "stdio",
|
||||
);
|
||||
if (transportInput == null) {
|
||||
return null;
|
||||
}
|
||||
const transportType =
|
||||
transportInput.trim() as McpServerUpsertInput["transportType"];
|
||||
if (
|
||||
transportType !== "stdio" &&
|
||||
transportType !== "sse" &&
|
||||
transportType !== "streamableHttp"
|
||||
) {
|
||||
window.alert('Transport type must be "stdio", "sse", or "streamableHttp".');
|
||||
return null;
|
||||
function createEnvEntries(
|
||||
input?: Record<string, string>,
|
||||
): Array<{ id: string; key: string; value: string }> {
|
||||
if (!input || Object.keys(input).length === 0) {
|
||||
return [{ id: crypto.randomUUID(), key: "", value: "" }];
|
||||
}
|
||||
return Object.entries(input).map(([key, value]) => ({
|
||||
id: crypto.randomUUID(),
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
if (transportType === "stdio") {
|
||||
const commandInput = window.prompt("Command", existing?.command ?? "");
|
||||
if (commandInput == null) {
|
||||
return null;
|
||||
}
|
||||
const command = commandInput.trim();
|
||||
if (!command) {
|
||||
window.alert("Command is required for stdio transport.");
|
||||
return null;
|
||||
}
|
||||
const argsInput = window.prompt(
|
||||
'Args (comma-separated, e.g. "-y, @modelcontextprotocol/server-github")',
|
||||
existing?.args?.join(", ") ?? "",
|
||||
);
|
||||
if (argsInput == null) {
|
||||
return null;
|
||||
}
|
||||
const cwdInput = window.prompt(
|
||||
"Working directory (optional)",
|
||||
existing?.cwd ?? "",
|
||||
);
|
||||
if (cwdInput == null) {
|
||||
return null;
|
||||
}
|
||||
const envInput = window.prompt(
|
||||
"Environment vars (comma-separated KEY=VALUE pairs, optional)",
|
||||
stringifyKeyValuePairs(existing?.env),
|
||||
);
|
||||
if (envInput == null) {
|
||||
return null;
|
||||
}
|
||||
const args = splitCsv(argsInput);
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
command,
|
||||
args: args.length > 0 ? args : undefined,
|
||||
cwd: cwdInput.trim() || undefined,
|
||||
env: parseKeyValuePairs(envInput),
|
||||
disabled: existing?.disabled ?? false,
|
||||
metadata: existing?.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
const urlInput = window.prompt("Server URL", existing?.url ?? "");
|
||||
if (urlInput == null) {
|
||||
return null;
|
||||
}
|
||||
const url = urlInput.trim();
|
||||
if (!url) {
|
||||
window.alert("URL is required for sse/streamableHttp transport.");
|
||||
return null;
|
||||
}
|
||||
const headersInput = window.prompt(
|
||||
"Headers (comma-separated KEY=VALUE pairs, optional)",
|
||||
stringifyKeyValuePairs(existing?.headers),
|
||||
);
|
||||
if (headersInput == null) {
|
||||
return null;
|
||||
}
|
||||
function createServerFormState(existing?: McpServer): McpServerFormState {
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
url,
|
||||
headers: parseKeyValuePairs(headersInput),
|
||||
name: existing?.name ?? "",
|
||||
previousName: existing?.name ?? "",
|
||||
transportType: existing?.transportType ?? "stdio",
|
||||
command: existing?.command ?? "",
|
||||
argsText: existing?.args?.join(", ") ?? "",
|
||||
cwd: existing?.cwd ?? "",
|
||||
envEntries: createEnvEntries(existing?.env),
|
||||
url: existing?.url ?? "",
|
||||
headersText: stringifyKeyValuePairs(existing?.headers),
|
||||
disabled: existing?.disabled ?? false,
|
||||
metadata: existing?.metadata,
|
||||
metadataText:
|
||||
existing?.metadata === undefined
|
||||
? ""
|
||||
: JSON.stringify(existing.metadata, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +173,13 @@ export function McpServersContent() {
|
||||
const [isOpeningSettingsFile, setIsOpeningSettingsFile] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [busyServerName, setBusyServerName] = useState<string | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"create" | "edit">("create");
|
||||
const [formState, setFormState] = useState<McpServerFormState>(() =>
|
||||
createServerFormState(),
|
||||
);
|
||||
const [formErrorMessage, setFormErrorMessage] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<McpServer | null>(null);
|
||||
|
||||
const applyResponse = useCallback((response: McpServersResponse) => {
|
||||
setServers(response.servers);
|
||||
@@ -231,7 +227,7 @@ export function McpServersContent() {
|
||||
};
|
||||
|
||||
const upsertServer = async (input: McpServerUpsertInput) => {
|
||||
setBusyServerName(input.name);
|
||||
setBusyServerName(input.previousName ?? input.name);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await desktopClient.invoke<McpServersResponse>(
|
||||
@@ -244,6 +240,7 @@ export function McpServersContent() {
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setBusyServerName(null);
|
||||
}
|
||||
@@ -268,24 +265,80 @@ export function McpServersContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddServer = async () => {
|
||||
const input = getServerInput();
|
||||
if (!input) {
|
||||
return;
|
||||
const buildServerInput = useCallback((form: McpServerFormState) => {
|
||||
const name = form.name.trim();
|
||||
if (!name) {
|
||||
throw new Error("Server name is required.");
|
||||
}
|
||||
await upsertServer(input);
|
||||
const env = form.envEntries.reduce<Record<string, string>>((acc, entry) => {
|
||||
const key = entry.key.trim();
|
||||
if (!key) {
|
||||
return acc;
|
||||
}
|
||||
acc[key] = entry.value;
|
||||
return acc;
|
||||
}, {});
|
||||
const metadataText = form.metadataText.trim();
|
||||
const metadata =
|
||||
metadataText.length > 0 ? JSON.parse(metadataText) : undefined;
|
||||
if (form.transportType === "stdio") {
|
||||
const command = form.command.trim();
|
||||
if (!command) {
|
||||
throw new Error("Command is required for stdio transport.");
|
||||
}
|
||||
const args = splitCsv(form.argsText);
|
||||
return {
|
||||
name,
|
||||
previousName: form.previousName.trim() || undefined,
|
||||
transportType: form.transportType,
|
||||
command,
|
||||
args: args.length > 0 ? args : undefined,
|
||||
cwd: form.cwd.trim() || undefined,
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
disabled: form.disabled,
|
||||
metadata,
|
||||
} satisfies McpServerUpsertInput;
|
||||
}
|
||||
const url = form.url.trim();
|
||||
if (!url) {
|
||||
throw new Error("URL is required for sse and streamableHttp transport.");
|
||||
}
|
||||
return {
|
||||
name,
|
||||
previousName: form.previousName.trim() || undefined,
|
||||
transportType: form.transportType,
|
||||
url,
|
||||
headers: parseKeyValuePairs(form.headersText),
|
||||
disabled: form.disabled,
|
||||
metadata,
|
||||
} satisfies McpServerUpsertInput;
|
||||
}, []);
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditorMode("create");
|
||||
setFormState(createServerFormState());
|
||||
setFormErrorMessage(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleEditServer = async (server: McpServer) => {
|
||||
const input = getServerInput(server);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
await upsertServer(input);
|
||||
const openEditDialog = (server: McpServer) => {
|
||||
setEditorMode("edit");
|
||||
setFormState(createServerFormState(server));
|
||||
setFormErrorMessage(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const _openMcpCatalog = () => {
|
||||
window.open("https://mcp.so", "_blank", "noopener,noreferrer");
|
||||
const handleSaveServer = async () => {
|
||||
setFormErrorMessage(null);
|
||||
try {
|
||||
const input = buildServerInput(formState);
|
||||
await upsertServer(input);
|
||||
setEditorOpen(false);
|
||||
} catch (error) {
|
||||
setFormErrorMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openSettingsFile = async () => {
|
||||
@@ -315,6 +368,39 @@ export function McpServersContent() {
|
||||
[servers],
|
||||
);
|
||||
|
||||
const updateEnvEntry = (
|
||||
id: string,
|
||||
field: "key" | "value",
|
||||
value: string,
|
||||
) => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
envEntries: current.envEntries.map((entry) =>
|
||||
entry.id === id ? { ...entry, [field]: value } : entry,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addEnvEntry = () => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
envEntries: [
|
||||
...current.envEntries,
|
||||
{ id: crypto.randomUUID(), key: "", value: "" },
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeEnvEntry = (id: string) => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
envEntries:
|
||||
current.envEntries.length === 1
|
||||
? [{ id: crypto.randomUUID(), key: "", value: "" }]
|
||||
: current.envEntries.filter((entry) => entry.id !== id),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
@@ -339,7 +425,7 @@ export function McpServersContent() {
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void handleAddServer()}>
|
||||
<Button size="sm" onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add MCP Server
|
||||
</Button>
|
||||
@@ -356,14 +442,6 @@ export function McpServersContent() {
|
||||
>
|
||||
{settingsPath || "Open settings file"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void openSettingsFile()}
|
||||
disabled={isOpeningSettingsFile}
|
||||
>
|
||||
Open config file
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-6 text-xs text-muted-foreground">
|
||||
{hasSettingsFile
|
||||
@@ -411,21 +489,11 @@ export function McpServersContent() {
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`View ${server.name}`}
|
||||
onClick={() => {
|
||||
window.alert(JSON.stringify(server, null, 2));
|
||||
}}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${server.name}`}
|
||||
onClick={() => void handleEditServer(server)}
|
||||
onClick={() => openEditDialog(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
@@ -434,16 +502,8 @@ export function McpServersContent() {
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${server.name}`}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Delete MCP server "${server.name}" from settings?`,
|
||||
)
|
||||
) {
|
||||
void deleteServer(server.name);
|
||||
}
|
||||
}}
|
||||
disabled={!server.disabled && isBusy}
|
||||
onClick={() => setDeleteTarget(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -452,7 +512,7 @@ export function McpServersContent() {
|
||||
onCheckedChange={(enabled) =>
|
||||
toggleServer(server, !enabled)
|
||||
}
|
||||
disabled={!server.disabled && isBusy}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${server.name}`}
|
||||
/>
|
||||
</div>
|
||||
@@ -488,7 +548,7 @@ export function McpServersContent() {
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Env:</span>{" "}
|
||||
{stringifyKeyValuePairs(server.env)}
|
||||
{stringifyRedactedKeyValuePairs(server.env)}
|
||||
</p>
|
||||
)}
|
||||
{server.headers &&
|
||||
@@ -507,6 +567,291 @@ export function McpServersContent() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={editorOpen}
|
||||
onOpenChange={(open) => {
|
||||
setEditorOpen(open);
|
||||
if (!open) {
|
||||
setFormErrorMessage(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editorMode === "edit" ? "Edit MCP Server" : "Add MCP Server"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the MCP server stored in{" "}
|
||||
<code className="font-mono">
|
||||
{settingsPath || "cline_mcp_settings.json"}
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-name">Server name</Label>
|
||||
<Input
|
||||
id="mcp-name"
|
||||
value={formState.name}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="github"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Transport type</Label>
|
||||
<Select
|
||||
value={formState.transportType}
|
||||
onValueChange={(value) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
transportType: value as McpTransportType,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select transport" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">stdio</SelectItem>
|
||||
<SelectItem value="sse">sse</SelectItem>
|
||||
<SelectItem value="streamableHttp">streamableHttp</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formState.transportType === "stdio" ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-command">Command</Label>
|
||||
<Input
|
||||
id="mcp-command"
|
||||
value={formState.command}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
command: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="npx"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-args">Args</Label>
|
||||
<Textarea
|
||||
id="mcp-args"
|
||||
value={formState.argsText}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
argsText: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="-y, @modelcontextprotocol/server-github"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-cwd">Working directory</Label>
|
||||
<Input
|
||||
id="mcp-cwd"
|
||||
value={formState.cwd}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
cwd: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="/path/to/project"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label>Environment variables</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addEnvEntry}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{formState.envEntries.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => removeEnvEntry(entry.id)}
|
||||
aria-label={`Remove env var ${entry.key || "row"}`}
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Input
|
||||
value={entry.key}
|
||||
onChange={(event) =>
|
||||
updateEnvEntry(entry.id, "key", event.target.value)
|
||||
}
|
||||
placeholder="KEY"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={entry.value}
|
||||
onChange={(event) =>
|
||||
updateEnvEntry(
|
||||
entry.id,
|
||||
"value",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="VALUE"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-url">Server URL</Label>
|
||||
<Input
|
||||
id="mcp-url"
|
||||
value={formState.url}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
url: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://example.com/mcp"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-headers">Headers</Label>
|
||||
<Textarea
|
||||
id="mcp-headers"
|
||||
value={formState.headersText}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
headersText: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Authorization=Bearer token"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-metadata">Metadata JSON</Label>
|
||||
<Textarea
|
||||
id="mcp-metadata"
|
||||
value={formState.metadataText}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
metadataText: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder='{"key":"value"}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Enabled</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disable the server without removing it from settings.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!formState.disabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
disabled: !enabled,
|
||||
}))
|
||||
}
|
||||
aria-label="Enable MCP server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formErrorMessage ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{formErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditorOpen(false)}
|
||||
disabled={busyServerName !== null}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSaveServer()}
|
||||
disabled={busyServerName !== null}
|
||||
>
|
||||
{busyServerName !== null
|
||||
? "Saving..."
|
||||
: editorMode === "edit"
|
||||
? "Save changes"
|
||||
: "Add server"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete MCP Server</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget
|
||||
? `Delete MCP server "${deleteTarget.name}" from settings?`
|
||||
: "Delete this MCP server from settings?"}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busyServerName !== null}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={busyServerName !== null || !deleteTarget}
|
||||
onClick={() => {
|
||||
if (deleteTarget) {
|
||||
void deleteServer(deleteTarget.name);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -695,7 +695,7 @@ export function RoutineSchedulesContent() {
|
||||
</div>
|
||||
|
||||
<p className="mb-6 text-xs text-muted-foreground">
|
||||
Routines run through the RPC scheduler (same backend as
|
||||
Routines run through the hub schedule service (same backend as
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">
|
||||
clite schedule
|
||||
</code>
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
import { primeExtensionsListsCache, RulesView } from "./extensions.view";
|
||||
import { primeExtensionsListsCache, RulesView } from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
import {
|
||||
ProviderDetailContent,
|
||||
|
||||
@@ -29,12 +29,11 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
|
||||
systemPrompt: undefined,
|
||||
maxIterations: undefined,
|
||||
enableTools: true,
|
||||
enableSpawn: true,
|
||||
enableTeams: true,
|
||||
enableSpawn: undefined,
|
||||
enableTeams: undefined,
|
||||
autoApproveTools: true,
|
||||
teamName: "app-team",
|
||||
missionStepInterval: 3,
|
||||
missionTimeIntervalMs: 120000,
|
||||
missionStepInterval: undefined,
|
||||
missionTimeIntervalMs: undefined,
|
||||
};
|
||||
|
||||
export function getInitialChatConfig(): ChatSessionConfig {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type AgentChunkEvent = {
|
||||
stream: string;
|
||||
chunk: string;
|
||||
ts: number;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
export type ReasoningDeltaEvent = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user