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:
Bee
2026-04-21 19:59:54 -07:00
committed by GitHub
parent 9670d4b238
commit 7a042fe257
305 changed files with 18261 additions and 19582 deletions
+9 -12
View File
@@ -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
View File
@@ -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
+6 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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",
-3
View File
@@ -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,
};
}
}
+114 -25
View File
@@ -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", () => {
+89 -21
View File
@@ -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(
+74 -70
View File
@@ -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
View File
@@ -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;
+10 -5
View File
@@ -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: [
+4 -1
View File
@@ -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 -112
View File
@@ -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);
}
+134
View File
@@ -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;
}
+2 -90
View File
@@ -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("-"));
-570
View File
@@ -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<
-409
View File
@@ -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);
});
});
-509
View File
@@ -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;
}
+29 -28
View File
@@ -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();
});
});
+3 -840
View File
@@ -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>;
+19 -13
View File
@@ -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,
+19 -13
View File
@@ -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,
+19 -13
View File
@@ -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,
+19 -13
View File
@@ -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,
+3 -3
View File
@@ -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);
+40 -16
View File
@@ -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;
+4 -4
View File
@@ -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");
+12 -18
View File
@@ -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) => {
+2 -2
View File
@@ -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
View File
@@ -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,
+20
View File
@@ -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
View File
@@ -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,
+33
View File
@@ -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]);
});
});
+197 -6
View File
@@ -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 -4
View File
@@ -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,
+44 -30
View File
@@ -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);
+255 -89
View File
@@ -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();
+17
View File
@@ -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,
});
}
+28 -6
View File
@@ -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,
},
+63 -15
View File
@@ -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 },
);
}
+268 -66
View File
@@ -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 {
+1 -1
View File
@@ -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 });
}
});
});
+108 -31
View File
@@ -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,
});
}
+82 -24
View File
@@ -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]",
+26 -21
View File
@@ -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);
+35
View File
@@ -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) => {
+29
View File
@@ -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(
{
+6
View File
@@ -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`,
+23 -59
View File
@@ -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(
+26
View File
@@ -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);
+36 -40
View File
@@ -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
View File
@@ -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 () => {},
};
}
+173
View File
@@ -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.");
}
+5 -28
View File
@@ -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",
);
});
});
+7 -68
View File
@@ -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;
}
-156
View File
@@ -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;
}
+2 -12
View File
@@ -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>;
}
+2 -2
View File
@@ -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$/,
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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",
+6 -5
View File
@@ -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 |
+106 -6
View File
@@ -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,
+216 -61
View File
@@ -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") {
+210 -51
View File
@@ -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;
}
+7 -4
View File
@@ -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;
};
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+28
View File
@@ -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);
@@ -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