diff --git a/sdk/AGENTS.md b/sdk/AGENTS.md index 5b8beccd8c..16fc47c854 100644 --- a/sdk/AGENTS.md +++ b/sdk/AGENTS.md @@ -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. diff --git a/sdk/ARCHITECTURE.md b/sdk/ARCHITECTURE.md index 450373e7e0..6cd82a47b6 100644 --- a/sdk/ARCHITECTURE.md +++ b/sdk/ARCHITECTURE.md @@ -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 diff --git a/sdk/CONTRIBUTION.md b/sdk/CONTRIBUTING.md similarity index 90% rename from sdk/CONTRIBUTION.md rename to sdk/CONTRIBUTING.md index e4b489680e..c8c1199041 100644 --- a/sdk/CONTRIBUTION.md +++ b/sdk/CONTRIBUTING.md @@ -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 diff --git a/sdk/DOC.md b/sdk/DOC.md index 1d42d10c2b..aa792e1e25 100644 --- a/sdk/DOC.md +++ b/sdk/DOC.md @@ -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. | --- diff --git a/sdk/README.md b/sdk/README.md index cf9439bcf0..d624f750ac 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -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"] ``` diff --git a/sdk/apps/cli/README.md b/sdk/apps/cli/README.md index 8344386455..35b2e71c46 100644 --- a/sdk/apps/cli/README.md +++ b/sdk/apps/cli/README.md @@ -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 ` | 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 ` | 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 ` | Explicitly enable one tool | -| `--tool-disable ` | Explicitly disable one tool | +| `-y, --yolo` | Skip tool approval prompts, enable `submit_and_exit`, and disable spawn/team tools by default | | `--team-name ` | Override the runtime team state name | -| `--mission-step-interval ` | Mission log update cadence in meaningful steps | -| `--mission-time-interval-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] ` - Legacy command alias for running a task -- `clite history|h [options]` - Legacy command alias for listing history +- `clite task|t [options] ` - 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 ` - Authenticate or seed provider credentials - `clite connect ` - Run a chat connector bridge (`telegram`, `gchat`, `whatsapp`) - `clite connect --stop [adapter]` - Stop connector bridge processes and their sessions -- `clite list ` - List configs, history, or hook paths - `clite schedule ` - Create and manage scheduled runs -- `clite sessions ` - 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 ` - Manage the local RPC runtime server +- `clite hub` - Manage the local hub daemon Connector shortcuts: @@ -346,11 +316,8 @@ Connector shortcuts: - `clite connect --help` - Show adapter-specific options and examples - `--hook-command ` - Run a shell command for connector events -RPC and schedule shortcuts: +Schedule shortcuts: -- `clite rpc [--address ]` - Manage the RPC server -- `clite rpc register --client-type --client-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 --cron "" --prompt "" --workspace ` - Create a scheduled run - `clite schedule ` - Manage schedules and execution history @@ -368,13 +335,6 @@ Auth quick-setup flags: - `-m, --modelid ` - `-b, --baseurl ` (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 "" with input ? [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 ` 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 [--client-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 ""` 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 `/settings/providers.json`. - User-added OpenAI-compatible provider model catalogs are persisted in `/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 diff --git a/sdk/apps/cli/package.json b/sdk/apps/cli/package.json index bca7d4dd31..0f403909ff 100644 --- a/sdk/apps/cli/package.json +++ b/sdk/apps/cli/package.json @@ -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", diff --git a/sdk/apps/cli/src/acp/acpAgent.ts b/sdk/apps/cli/src/acp/acpAgent.ts index 7c41190e01..e49a7cdf0e 100644 --- a/sdk/apps/cli/src/acp/acpAgent.ts +++ b/sdk/apps/cli/src/acp/acpAgent.ts @@ -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, }; } } diff --git a/sdk/apps/cli/src/cli.e2e.test.ts b/sdk/apps/cli/src/cli.e2e.test.ts index a4be0d9a6e..0d96d0bf37 100644 --- a/sdk/apps/cli/src/cli.e2e.test.ts +++ b/sdk/apps/cli/src/cli.e2e.test.ts @@ -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 "); 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", () => { diff --git a/sdk/apps/cli/src/commands/config.ts b/sdk/apps/cli/src/commands/config.ts index 2145bb3599..5d3811262c 100644 --- a/sdk/apps/cli/src/commands/config.ts +++ b/sdk/apps/cli/src/commands/config.ts @@ -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 { @@ -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 { - 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>> { + 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( diff --git a/sdk/apps/cli/src/commands/doctor.test.ts b/sdk/apps/cli/src/commands/doctor.test.ts index b5b3c48809..611cf8e01e 100644 --- a/sdk/apps/cli/src/commands/doctor.test.ts +++ b/sdk/apps/cli/src/commands/doctor.test.ts @@ -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); }); }); diff --git a/sdk/apps/cli/src/commands/doctor.ts b/sdk/apps/cli/src/commands/doctor.ts index 7c6a3ea770..a1b6076872 100644 --- a/sdk/apps/cli/src/commands/doctor.ts +++ b/sdk/apps/cli/src/commands/doctor.ts @@ -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(); - 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 | undefined { type ConnectorFieldKey = keyof Omit< ActiveConnectorRecord, - "type" | "pid" | "rpcAddress" + "type" | "pid" | "hubUrl" >; -type ConnectorFieldExtractor = ( - p: Record, -) => string | number | undefined; - const connectorFieldExtractors: Record< ConnectorFieldKey, - ConnectorFieldExtractor + (p: Record) => 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 = { +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 + Omit > = {}; for (const key of config.required) { const value = connectorFieldExtractors[key](parsed); @@ -441,7 +359,7 @@ function readActiveConnectorRecord( (fields as Record)[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 { - const health = await getRpcServerHealth(address); +async function collectDoctorStatus(cwd: string): Promise { + 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 { 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 ", - "RPC server address", - process.env.CLINE_RPC_ADDRESS || getRpcServerDefaultAddress(), - ) + .option("--cwd ", "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; diff --git a/sdk/apps/cli/src/commands/history.test.ts b/sdk/apps/cli/src/commands/history.test.ts index 4f0e231b3f..2acacedbcc 100644 --- a/sdk/apps/cli/src/commands/history.test.ts +++ b/sdk/apps/cli/src/commands/history.test.ts @@ -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: [ diff --git a/sdk/apps/cli/src/commands/history.ts b/sdk/apps/cli/src/commands/history.ts index c1e064eba3..7e1e759e3d 100644 --- a/sdk/apps/cli/src/commands/history.ts +++ b/sdk/apps/cli/src/commands/history.ts @@ -104,6 +104,7 @@ async function runHistoryUpdate( export async function runHistoryList(input: { limit: number; outputMode: CliOutputMode; + workspaceRoot?: string; io?: HistoryIo; }): Promise { 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([])); diff --git a/sdk/apps/cli/src/commands/hook.ts b/sdk/apps/cli/src/commands/hook.ts index e094ca21bd..b7b93ca388 100644 --- a/sdk/apps/cli/src/commands/hook.ts +++ b/sdk/apps/cli/src/commands/hook.ts @@ -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 { await appendHookAudit(payload); await handleSessionHookEvent(payload); @@ -43,27 +29,6 @@ async function handleHookPayload(payload: HookEventPayload): Promise { } } -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).length === 0 - ) { - return ""; - } - if (typeof value === "string") { - return truncate(value, 100); - } - return truncate(JSON.stringify(value), 100); -} diff --git a/sdk/apps/cli/src/commands/hub.ts b/sdk/apps/cli/src/commands/hub.ts new file mode 100644 index 0000000000..9a0cae24f9 --- /dev/null +++ b/sdk/apps/cli/src/commands/hub.ts @@ -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 { + 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 = + (fn: (...args: T) => Promise) => + 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 ", "Workspace root", process.cwd()) + .option("--host ", "Hub host") + .option("--port ", "Hub port", (value) => Number.parseInt(value, 10)) + .option("--pathname ", "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((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; +} diff --git a/sdk/apps/cli/src/commands/program.ts b/sdk/apps/cli/src/commands/program.ts index ce287cc294..cbd3410094 100644 --- a/sdk/apps/cli/src/commands/program.ts +++ b/sdk/apps/cli/src/commands/program.ts @@ -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 ", "Configuration directory") .option("-c, --cwd ", "Working directory") - .option("--enable-spawn") // alias for --spawn .option( "--hooks-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 ") - .option( - "--mission-step-interval ", - "Mission log update cadence in meaningful steps", - ) - .option( - "--mission-time-interval-ms ", - "Mission log update cadence in milliseconds", - ) .option("-m, --model ", "Model to use for the task") .option("-p, --plan", "Run in plan mode") .option("-P, --provider ", "Provider id (default: cline)") @@ -85,23 +56,14 @@ export function addRootOptions(cmd: Command): Command { "--sandbox-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 ", "Override the system prompt") .option("-T, --taskId ", "Resume an existing task by ID") .option("--team-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 ", "Optional timeout in seconds (applies only when provided)", ) - .option("--timings", "Show timing details") - .option("--tool-disable ", "Explicitly disable one tool", collect, []) - .option("--tool-enable ", "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("-")); diff --git a/sdk/apps/cli/src/commands/rpc-runtime.ts b/sdk/apps/cli/src/commands/rpc-runtime.ts deleted file mode 100644 index 48664bdabc..0000000000 --- a/sdk/apps/cli/src/commands/rpc-runtime.ts +++ /dev/null @@ -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>[0]; -type HookSessionShutdownContext = Parameters< - NonNullable ->[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(); - private readonly conversationRoots = new Map(); - private readonly rootMembers = new Map< - string, - { agents: Set; conversations: Set } - >(); - 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 { - 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; - conversations: Set; - } { - let members = this.rootMembers.get(rootSessionId); - if (!members) { - members = { - agents: new Set(), - conversations: new Set(), - }; - 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(); - const activeSessions = new Set(); - 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["core"], - reason: string, - ): Promise => { - 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 => { - 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; - }, - }); - 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(); - }, - }; -} diff --git a/sdk/apps/cli/src/commands/rpc-runtime/event-bridge.test.ts b/sdk/apps/cli/src/commands/rpc-runtime/event-bridge.test.ts deleted file mode 100644 index 2f55ae3c02..0000000000 --- a/sdk/apps/cli/src/commands/rpc-runtime/event-bridge.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/sdk/apps/cli/src/commands/rpc-runtime/event-bridge.ts b/sdk/apps/cli/src/commands/rpc-runtime/event-bridge.ts deleted file mode 100644 index aa2e963289..0000000000 --- a/sdk/apps/cli/src/commands/rpc-runtime/event-bridge.ts +++ /dev/null @@ -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 { - 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, - 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, - }); - }); -} diff --git a/sdk/apps/cli/src/commands/rpc-runtime/provider-actions.ts b/sdk/apps/cli/src/commands/rpc-runtime/provider-actions.ts deleted file mode 100644 index e3a7b3b118..0000000000 --- a/sdk/apps/cli/src/commands/rpc-runtime/provider-actions.ts +++ /dev/null @@ -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, - }; -} diff --git a/sdk/apps/cli/src/commands/rpc-runtime/provider-registry.test.ts b/sdk/apps/cli/src/commands/rpc-runtime/provider-registry.test.ts index 35aa8640ab..3f761a56b5 100644 --- a/sdk/apps/cli/src/commands/rpc-runtime/provider-registry.test.ts +++ b/sdk/apps/cli/src/commands/rpc-runtime/provider-registry.test.ts @@ -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); diff --git a/sdk/apps/cli/src/commands/rpc-runtime/session-helpers.test.ts b/sdk/apps/cli/src/commands/rpc-runtime/session-helpers.test.ts deleted file mode 100644 index ed2b31793b..0000000000 --- a/sdk/apps/cli/src/commands/rpc-runtime/session-helpers.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@clinebot/core", async () => { - const actual = - await vi.importActual("@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(); - }); -}); diff --git a/sdk/apps/cli/src/commands/rpc-runtime/session-helpers.ts b/sdk/apps/cli/src/commands/rpc-runtime/session-helpers.ts deleted file mode 100644 index dd8b94d34e..0000000000 --- a/sdk/apps/cli/src/commands/rpc-runtime/session-helpers.ts +++ /dev/null @@ -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 { - 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[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"); -} diff --git a/sdk/apps/cli/src/commands/rpc-runtime/types.ts b/sdk/apps/cli/src/commands/rpc-runtime/types.ts index e23aadbe8e..94a9734c94 100644 --- a/sdk/apps/cli/src/commands/rpc-runtime/types.ts +++ b/sdk/apps/cli/src/commands/rpc-runtime/types.ts @@ -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< diff --git a/sdk/apps/cli/src/commands/rpc.test.ts b/sdk/apps/cli/src/commands/rpc.test.ts deleted file mode 100644 index 31f1c96caa..0000000000 --- a/sdk/apps/cli/src/commands/rpc.test.ts +++ /dev/null @@ -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 = {}; - - 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); - }); -}); diff --git a/sdk/apps/cli/src/commands/rpc.ts b/sdk/apps/cli/src/commands/rpc.ts deleted file mode 100644 index 8f899a4a22..0000000000 --- a/sdk/apps/cli/src/commands/rpc.ts +++ /dev/null @@ -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((r) => setTimeout(r, ms)); - -function collectMeta(value: string, previous: string[]): string[] { - return previous.concat(value); -} - -function parseMetaEntries(entries: string[]): Record { - const metadata: Record = {}; - 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 { - 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 { - 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 { - 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> | 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((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 { - 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 { - 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 { - 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 ${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 ", "Client ID") - .option("--client-type ", "Client type", "desktop") - .option( - "--meta ", - "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; -} diff --git a/sdk/apps/cli/src/commands/schedule.test.ts b/sdk/apps/cli/src/commands/schedule.test.ts index b51ba9de68..0d28726c11 100644 --- a/sdk/apps/cli/src/commands/schedule.test.ts +++ b/sdk/apps/cli/src/commands/schedule.test.ts @@ -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(); }); }); diff --git a/sdk/apps/cli/src/commands/schedule.ts b/sdk/apps/cli/src/commands/schedule.ts index 9dd5b05ac2..643fb7b533 100644 --- a/sdk/apps/cli/src/commands/schedule.ts +++ b/sdk/apps/cli/src/commands/schedule.ts @@ -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 | 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; -} - -function mergeScheduleDeliveryMetadata( - base: Record | undefined, - delivery: { - deliveryAdapter?: string; - deliveryThread?: string; - deliveryChannel?: string; - deliveryBot?: string; - }, -): Record | 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) - : {}; - next.delivery = { - ...existingDelivery, - ...(adapter ? { adapter } : {}), - ...(threadId ? { threadId } : {}), - ...(channelId ? { channelId } : {}), - ...(userName ? { userName } : {}), - }; - return next; -} - -function mergeScheduleAutonomousMetadata( - base: Record | undefined, - autonomous: { - autonomous?: true; - noAutonomous?: true; - idleTimeout?: string; - pollInterval?: string; - }, -): Record | 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) - : {}; - 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): boolean { - return ( - !!opts.metadataJson || - !!opts.deliveryAdapter || - !!opts.deliveryThread || - !!opts.deliveryChannel || - !!opts.deliveryBot || - !!opts.autonomous || - !!opts.noAutonomous || - !!opts.idleTimeout || - !!opts.pollInterval - ); -} - -function mergeScheduleMetadata( - base: Record | undefined, - opts: { - deliveryAdapter?: string; - deliveryThread?: string; - deliveryChannel?: string; - deliveryBot?: string; - autonomous?: true; - noAutonomous?: true; - idleTimeout?: string; - pollInterval?: string; - }, -): Record | 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 ", "RPC server address") - .option("--json", "Output as JSON"); -} - -function addDeliveryOptions(cmd: Command): Command { - return cmd - .option("--delivery-adapter ", "Delivery adapter name") - .option("--delivery-bot ", "Delivery bot user name") - .option("--delivery-channel ", "Delivery channel ID") - .option("--delivery-thread ", "Delivery thread ID"); -} - -function addAutonomousOptions(cmd: Command): Command { - return cmd - .option("--autonomous", "Enable autonomous mode") - .option("--no-autonomous", "Disable autonomous mode") - .option("--idle-timeout ", "Autonomous idle timeout in seconds") - .option("--poll-interval ", "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( fn: (...args: T) => Promise, ): (...args: T) => Promise { @@ -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("", "Schedule name") - .requiredOption("--cron ", "Cron pattern") - .requiredOption("--prompt ", "Task prompt") - .requiredOption("--workspace ", "Workspace root path") - .option("--created-by ", "Creator name") - .option("--cwd ", "Working directory") - .option("--disabled", "Create in disabled state") - .option("--max-iterations ", "Maximum iterations") - .option("--max-parallel ", "Max parallel executions", "1") - .option("--metadata-json ", "Metadata as JSON object") - .option("--mode ", "Execution mode") - .option("--model ", "Model to use", "openai/gpt-5.3-codex") - .option("--provider ", "Provider ID", "cline") - .option("--system-prompt ", "System prompt override") - .option("--tags ", "Comma-separated tags") - .option("--timeout ", "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"); - 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") - .option("--to ", "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"); - 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") - .option("--limit ", "Maximum number of results", "20") - .option("--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("", "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; - if (isJsonPath(sourcePath)) { - parsed = JSON.parse(sourceRaw) as Record; - } else { - const yaml = await import("yaml"); - parsed = yaml.parse(sourceRaw) as Record; - } - 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) - : 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 ", "Maximum number of results", "100") - .option("--tags ", "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"); - 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"); - 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"); - 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"); - 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 ", "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") - .option("--clear-max-iterations", "Clear max iterations") - .option("--clear-timeout", "Clear timeout") - .option("--cron ", "New cron pattern") - .option("--cwd ", "New working directory") - .option("--disabled", "Disable the schedule") - .option("--enabled", "Enable the schedule") - .option("--max-iterations ", "New max iterations") - .option("--max-parallel ", "New max parallel executions") - .option("--metadata-json ", "New metadata as JSON object") - .option("--mode ", "New execution mode") - .option("--model ", "New model") - .option("--name ", "New name") - .option("--pause", "Pause the schedule") - .option("--prompt ", "New prompt") - .option("--provider ", "New provider ID") - .option("--resume", "Resume the schedule") - .option("--system-prompt ", "New system prompt") - .option("--tags ", "New comma-separated tags") - .option("--timeout ", "New timeout in seconds") - .option("--workspace ", "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 | 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 | 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; } diff --git a/sdk/apps/cli/src/commands/schedule/client.ts b/sdk/apps/cli/src/commands/schedule/client.ts new file mode 100644 index 0000000000..c8e0745d91 --- /dev/null +++ b/sdk/apps/cli/src/commands/schedule/client.ts @@ -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, + ): Promise> { + 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; + } + + async getActiveScheduledExecutions() { + return (await this.command("schedule.active")).executions; + } + + async createSchedule(payload: Record) { + 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) { + return (await this.command("schedule.list_executions", payload)).executions; + } + + async listSchedules(payload: Record) { + 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) { + 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({}), + }; + } +} diff --git a/sdk/apps/cli/src/commands/schedule/common.ts b/sdk/apps/cli/src/commands/schedule/common.ts new file mode 100644 index 0000000000..4cf1c1650a --- /dev/null +++ b/sdk/apps/cli/src/commands/schedule/common.ts @@ -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 | 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; +} + +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 | undefined, + delivery: { + deliveryAdapter?: string; + deliveryThread?: string; + deliveryChannel?: string; + deliveryBot?: string; + }, +): Record | 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) + : {}; + next.delivery = { + ...existingDelivery, + ...(adapter ? { adapter } : {}), + ...(threadId ? { threadId } : {}), + ...(channelId ? { channelId } : {}), + ...(userName ? { userName } : {}), + }; + return next; +} + +export function mergeScheduleAutonomousMetadata( + base: Record | undefined, + autonomous: { + autonomous?: true; + noAutonomous?: true; + idleTimeout?: string; + pollInterval?: string; + }, +): Record | 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) + : {}; + 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): 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 | undefined, + opts: { + deliveryAdapter?: string; + deliveryThread?: string; + deliveryChannel?: string; + deliveryBot?: string; + autonomous?: true; + noAutonomous?: true; + idleTimeout?: string; + pollInterval?: string; + }, +): Record | 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 ", "Hub server address") + .option("--json", "Output as JSON"); +} + +export function addDeliveryOptions(cmd: Command): Command { + return cmd + .option("--delivery-adapter ", "Delivery adapter name") + .option("--delivery-bot ", "Delivery bot user name") + .option("--delivery-channel ", "Delivery channel ID") + .option("--delivery-thread ", "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 ", "Autonomous idle timeout in seconds") + .option("--poll-interval ", "Autonomous poll interval in seconds"); +} diff --git a/sdk/apps/cli/src/commands/schedule/handlers.ts b/sdk/apps/cli/src/commands/schedule/handlers.ts new file mode 100644 index 0000000000..34a3784337 --- /dev/null +++ b/sdk/apps/cli/src/commands/schedule/handlers.ts @@ -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("", "Schedule name") + .requiredOption("--cron ", "Cron pattern") + .requiredOption("--prompt ", "Task prompt") + .requiredOption("--workspace ", "Workspace root path") + .option("--created-by ", "Creator name") + .option("--cwd ", "Working directory") + .option("--disabled", "Create in disabled state") + .option("--max-iterations ", "Maximum iterations") + .option("--max-parallel ", "Max parallel executions", "1") + .option("--metadata-json ", "Metadata as JSON object") + .option("--mode ", "Execution mode") + .option("--model ", "Model to use", "openai/gpt-5.3-codex") + .option("--provider ", "Provider ID", "cline") + .option("--system-prompt ", "System prompt override") + .option("--tags ", "Comma-separated tags") + .option("--timeout ", "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"); + 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"); + 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") + .option("--limit ", "Maximum number of results", "20") + .option("--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 ", "Maximum number of results", "100") + .option("--tags ", "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"); + 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"); + 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"); + 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"); + 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 ", "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); +} diff --git a/sdk/apps/cli/src/commands/schedule/import-export.ts b/sdk/apps/cli/src/commands/schedule/import-export.ts new file mode 100644 index 0000000000..27518f5cc3 --- /dev/null +++ b/sdk/apps/cli/src/commands/schedule/import-export.ts @@ -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") + .option("--to ", "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("", "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; + if (isJsonPath(sourcePath)) { + parsed = JSON.parse(sourceRaw) as Record; + } else { + const yaml = await import("yaml"); + parsed = yaml.parse(sourceRaw) as Record; + } + 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) + : 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") + .option("--clear-max-iterations", "Clear max iterations") + .option("--clear-timeout", "Clear timeout") + .option("--cron ", "New cron pattern") + .option("--cwd ", "New working directory") + .option("--disabled", "Disable the schedule") + .option("--enabled", "Enable the schedule") + .option("--max-iterations ", "New max iterations") + .option("--max-parallel ", "New max parallel executions") + .option("--metadata-json ", "New metadata as JSON object") + .option("--mode ", "New execution mode") + .option("--model ", "New model") + .option("--name ", "New name") + .option("--pause", "Pause the schedule") + .option("--prompt ", "New prompt") + .option("--provider ", "New provider ID") + .option("--resume", "Resume the schedule") + .option("--system-prompt ", "New system prompt") + .option("--tags ", "New comma-separated tags") + .option("--timeout ", "New timeout in seconds") + .option("--workspace ", "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 | undefined; + if (hasMetadataPatchOpts(opts)) { + const current = (await client.getSchedule(scheduleId)) as + | { metadata?: Record } + | 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(); + } + }), + ); +} diff --git a/sdk/apps/cli/src/commands/schedule/types.ts b/sdk/apps/cli/src/commands/schedule/types.ts new file mode 100644 index 0000000000..60fac44f64 --- /dev/null +++ b/sdk/apps/cli/src/commands/schedule/types.ts @@ -0,0 +1,8 @@ +export interface CommandIo { + writeln: (text?: string) => void; + writeErr: (text: string) => void; +} + +export type ScheduleActionWrapper = ( + fn: (...args: T) => Promise, +) => (...args: T) => Promise; diff --git a/sdk/apps/cli/src/connectors/adapters/discord.ts b/sdk/apps/cli/src/connectors/adapters/discord.ts index 192073284b..153c59a8a2 100644 --- a/sdk/apps/cli/src/connectors/adapters/discord.ts +++ b/sdk/apps/cli/src/connectors/adapters/discord.ts @@ -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 { +): Promise { 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; bindingsPath: string; - baseStartRequest: RpcChatStartSessionRequest; + baseStartRequest: ChatStartSessionRequest; rawMessage: unknown; errorLabel: string; }): Promise { @@ -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, diff --git a/sdk/apps/cli/src/connectors/adapters/gchat.ts b/sdk/apps/cli/src/connectors/adapters/gchat.ts index 8d64ef7a62..f4d8d55624 100644 --- a/sdk/apps/cli/src/connectors/adapters/gchat.ts +++ b/sdk/apps/cli/src/connectors/adapters/gchat.ts @@ -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 { +): Promise { 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; bindingsPath: string; - baseStartRequest: RpcChatStartSessionRequest; + baseStartRequest: ChatStartSessionRequest; rawMessage: unknown; errorLabel: string; }): Promise { @@ -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, diff --git a/sdk/apps/cli/src/connectors/adapters/linear.ts b/sdk/apps/cli/src/connectors/adapters/linear.ts index fef21f4d61..bdd3b78231 100644 --- a/sdk/apps/cli/src/connectors/adapters/linear.ts +++ b/sdk/apps/cli/src/connectors/adapters/linear.ts @@ -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 { +): Promise { 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; bindingsPath: string; - baseStartRequest: RpcChatStartSessionRequest; + baseStartRequest: ChatStartSessionRequest; rawMessage: unknown; errorLabel: string; }): Promise { @@ -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, diff --git a/sdk/apps/cli/src/connectors/adapters/slack.ts b/sdk/apps/cli/src/connectors/adapters/slack.ts index 573e2967c4..4212e0b0d9 100644 --- a/sdk/apps/cli/src/connectors/adapters/slack.ts +++ b/sdk/apps/cli/src/connectors/adapters/slack.ts @@ -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 { +): Promise { 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; bindingsPath: string; - baseStartRequest: RpcChatStartSessionRequest; + baseStartRequest: ChatStartSessionRequest; rawMessage: unknown; errorLabel: string; }): Promise { @@ -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, diff --git a/sdk/apps/cli/src/connectors/adapters/telegram.ts b/sdk/apps/cli/src/connectors/adapters/telegram.ts index 66b4c02e3e..76aefb3c15 100644 --- a/sdk/apps/cli/src/connectors/adapters/telegram.ts +++ b/sdk/apps/cli/src/connectors/adapters/telegram.ts @@ -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 { +): Promise { 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; bindingsPath: string; - baseStartRequest: RpcChatStartSessionRequest; + baseStartRequest: ChatStartSessionRequest; rawMessage: unknown; errorLabel: string; }): Promise { @@ -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, diff --git a/sdk/apps/cli/src/connectors/adapters/whatsapp.ts b/sdk/apps/cli/src/connectors/adapters/whatsapp.ts index 2065ace425..655bbf447a 100644 --- a/sdk/apps/cli/src/connectors/adapters/whatsapp.ts +++ b/sdk/apps/cli/src/connectors/adapters/whatsapp.ts @@ -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 { - const instanceKey = resolveInstanceKey({ - phoneNumberId: options.phoneNumberId, - userName: options.userName, - }); +): Promise { 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; bindingsPath: string; - baseStartRequest: RpcChatStartSessionRequest; + baseStartRequest: ChatStartSessionRequest; rawMessage: unknown; errorLabel: string; }): Promise { @@ -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, diff --git a/sdk/apps/cli/src/connectors/common.ts b/sdk/apps/cli/src/connectors/common.ts index 5f3dab2e72..28c27a3458 100644 --- a/sdk/apps/cli/src/connectors/common.ts +++ b/sdk/apps/cli/src/connectors/common.ts @@ -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; parentSessionId?: string | null; @@ -295,7 +295,7 @@ export function parseLocalRowMetadata(row: { } export async function readSessionReplyText( - client: RpcSessionClient, + client: HubSessionClient, sessionId: string, ): Promise { const session = await client.getSession(sessionId); diff --git a/sdk/apps/cli/src/connectors/connector-host.ts b/sdk/apps/cli/src/connectors/connector-host.ts index 6288e15d7d..b0498c0502 100644 --- a/sdk/apps/cli/src/connectors/connector-host.ts +++ b/sdk/apps/cli/src/connectors/connector-host.ts @@ -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; text: string; - client: RpcSessionClient; + client: HubSessionClient; pendingApprovals: Map; - baseStartRequest: RpcChatStartSessionRequest; + baseStartRequest: ChatStartSessionRequest; explicitSystemPrompt: string | undefined; clientId: string; logger: CliLoggerAdapter; @@ -82,7 +102,7 @@ export async function handleConnectorUserTurn< }) => Promise; onDescribe?: ( currentState: TState, - baseStartRequest: RpcChatStartSessionRequest, + baseStartRequest: ChatStartSessionRequest, thread: Thread, ) => Promise | 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; text: string; - client: RpcSessionClient; + client: HubSessionClient; clientId: string; pendingApprovals: Map; deniedReason: string; diff --git a/sdk/apps/cli/src/connectors/runtime-turn.ts b/sdk/apps/cli/src/connectors/runtime-turn.ts index 960a2c4dac..5cc62bafca 100644 --- a/sdk/apps/cli/src/connectors/runtime-turn.ts +++ b/sdk/apps/cli/src/connectors/runtime-turn.ts @@ -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; diff --git a/sdk/apps/cli/src/connectors/session-runtime.test.ts b/sdk/apps/cli/src/connectors/session-runtime.test.ts index aeae7f7853..369c29a1eb 100644 --- a/sdk/apps/cli/src/connectors/session-runtime.test.ts +++ b/sdk/apps/cli/src/connectors/session-runtime.test.ts @@ -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"); diff --git a/sdk/apps/cli/src/connectors/session-runtime.ts b/sdk/apps/cli/src/connectors/session-runtime.ts index d15611a54d..4d6da10b44 100644 --- a/sdk/apps/cli/src/connectors/session-runtime.ts +++ b/sdk/apps/cli/src/connectors/session-runtime.ts @@ -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 { +}): Promise { 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( - 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; - 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(input: { thread: Thread; - client: RpcSessionClient; + client: HubSessionClient; bindingsPath: string; - baseStartRequest: RpcChatStartSessionRequest; + baseStartRequest: ChatStartSessionRequest; errorLabel: string; }): Promise { const threadState = await loadThreadState( @@ -302,7 +296,7 @@ export async function stopConnectorSessions(input: { localMatcher: (metadata: Record | undefined) => boolean; rpcMatcher: (metadata: Record | undefined) => boolean; }): Promise { - 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) => { diff --git a/sdk/apps/cli/src/connectors/task-updates.ts b/sdk/apps/cli/src/connectors/task-updates.ts index ada63c25b7..c0aa7de2eb 100644 --- a/sdk/apps/cli/src/connectors/task-updates.ts +++ b/sdk/apps/cli/src/connectors/task-updates.ts @@ -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; diff --git a/sdk/apps/cli/src/logging/adapter.ts b/sdk/apps/cli/src/logging/adapter.ts index f95624060c..dccde84af0 100644 --- a/sdk/apps/cli/src/logging/adapter.ts +++ b/sdk/apps/cli/src/logging/adapter.ts @@ -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): CliLoggerAdapter; } interface CreateCliLoggerAdapterInput { runtime: "cli" | "rpc-runtime"; component?: string; - runtimeConfig?: RpcChatRuntimeLoggerConfig; + runtimeConfig?: RuntimeLoggerConfig; } const LOG_LEVELS: ReadonlySet = new Set([ @@ -57,8 +57,8 @@ function normalizeLogLevel(value: string | undefined): LevelWithSilent { function normalizeRuntimeConfig(input: { runtime: "cli" | "rpc-runtime"; - runtimeConfig?: RpcChatRuntimeLoggerConfig; -}): Required { + runtimeConfig?: RuntimeLoggerConfig; +}): Required { const base = input.runtimeConfig; const defaultDestination = join( resolveClineDataDir(), @@ -90,7 +90,7 @@ function normalizeRuntimeConfig(input: { } function getOrCreatePinoLogger( - config: Required, + config: Required, runtime: "cli" | "rpc-runtime", ): PinoLogger { if (!config.enabled) { @@ -266,7 +266,7 @@ function createCoreLogger(logger: PinoLogger): BasicLogger { function createAdapterFromPino( logger: PinoLogger, - runtimeConfig: Required, + runtimeConfig: Required, ): CliLoggerAdapter { return { pino: logger, diff --git a/sdk/apps/cli/src/main.test.ts b/sdk/apps/cli/src/main.test.ts index 22269d566b..7b4bd42bf0 100644 --- a/sdk/apps/cli/src/main.test.ts +++ b/sdk/apps/cli/src/main.test.ts @@ -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(); diff --git a/sdk/apps/cli/src/main.ts b/sdk/apps/cli/src/main.ts index 68b5499e29..d716a16c46 100644 --- a/sdk/apps/cli/src/main.ts +++ b/sdk/apps/cli/src/main.ts @@ -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, - overrides: Record, -): Record { - const out: Record = { ...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 { 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 { 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 { 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 { 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 { 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 { 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 { .option("-v, --verbose", "Show verbose output") .option("--config ", "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 { } setCurrentOutputMode(args.outputMode); const defaultToolAutoApprove = args.defaultToolAutoApprove; - const mergedToolPolicies = mergeToolPolicies({}, args.toolPolicies); const toolPolicies: Record = { "*": { 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 { 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 { 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 { }, 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 { return; } if (rewrittenTeamPrompt.kind === "rewritten") { - await enableTeamsForPrompt(config); await runAgent( rewrittenTeamPrompt.prompt, config, @@ -930,7 +958,6 @@ export async function runCli(): Promise { return; } if (rewrittenTeamPrompt.kind === "rewritten") { - await enableTeamsForPrompt(config); await runAgent( rewrittenTeamPrompt.prompt, config, diff --git a/sdk/apps/cli/src/runtime/prompt.test.ts b/sdk/apps/cli/src/runtime/prompt.test.ts new file mode 100644 index 0000000000..3a2eb53b85 --- /dev/null +++ b/sdk/apps/cli/src/runtime/prompt.test.ts @@ -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]); + }); +}); diff --git a/sdk/apps/cli/src/runtime/prompt.ts b/sdk/apps/cli/src/runtime/prompt.ts index 2f74ebe29b..3578d87359 100644 --- a/sdk/apps/cli/src/runtime/prompt.ts +++ b/sdk/apps/cli/src/runtime/prompt.ts @@ -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 = { + 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 { - 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, + }; } diff --git a/sdk/apps/cli/src/runtime/run-agent.test.ts b/sdk/apps/cli/src/runtime/run-agent.test.ts index 5e035d8e72..264d731a4c 100644 --- a/sdk/apps/cli/src/runtime/run-agent.test.ts +++ b/sdk/apps/cli/src/runtime/run-agent.test.ts @@ -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, diff --git a/sdk/apps/cli/src/runtime/run-agent.ts b/sdk/apps/cli/src/runtime/run-agent.ts index a4e554a920..e44f87cca8 100644 --- a/sdk/apps/cli/src/runtime/run-agent.ts +++ b/sdk/apps/cli/src/runtime/run-agent.ts @@ -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 { + // 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); diff --git a/sdk/apps/cli/src/runtime/run-interactive.ts b/sdk/apps/cli/src/runtime/run-interactive.ts index 663e3ef80b..6baa82fa27 100644 --- a/sdk/apps/cli/src/runtime/run-interactive.ts +++ b/sdk/apps/cli/src/runtime/run-interactive.ts @@ -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> | undefined; + let runtimeHooks: ReturnType | undefined; + let unsubscribeAgent = () => {}; + let unsubscribePendingPrompts = () => {}; + let startupPromise: Promise | 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>, + started: NonNullable< + Awaited> + > extends infer T + ? T extends { start: (...args: never[]) => Promise } + ? 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> = [], + sessionMetadata?: Record, + ) => { + 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 = { + 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>, ) => { - 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 => { + 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(); diff --git a/sdk/apps/cli/src/runtime/tools.ts b/sdk/apps/cli/src/runtime/tools.ts new file mode 100644 index 0000000000..bf1a17706a --- /dev/null +++ b/sdk/apps/cli/src/runtime/tools.ts @@ -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, + }); +} diff --git a/sdk/apps/cli/src/session/session.test.ts b/sdk/apps/cli/src/session/session.test.ts index c4396283f2..29de441787 100644 --- a/sdk/apps/cli/src/session/session.test.ts +++ b/sdk/apps/cli/src/session/session.test.ts @@ -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, }, diff --git a/sdk/apps/cli/src/session/session.ts b/sdk/apps/cli/src/session/session.ts index b35f36c7ba..49995dc761 100644 --- a/sdk/apps/cli/src/session/session.ts +++ b/sdk/apps/cli/src/session/session.ts @@ -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; }): Promise { - 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( run: (core: ClineCore) => Promise, - options?: { forceLocalBackend?: boolean; logger?: BasicLogger }, + options?: { + forceLocalBackend?: boolean; + logger?: BasicLogger; + cwd?: string; + workspaceRoot?: string; + }, ): Promise { 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( export async function listSessions( limit = 200, + options?: { workspaceRoot?: string }, ): Promise { - 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 { - 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 { - await withCliCore(async (core) => { - await core.handleHookEvent(payload); - }); + await withCliCore( + async (core) => { + await core.handleHookEvent(payload); + }, + { forceLocalBackend: true }, + ); } diff --git a/sdk/apps/cli/src/tui/components/ConfigView.ts b/sdk/apps/cli/src/tui/components/ConfigView.ts index 9157467968..ba9392f743 100644 --- a/sdk/apps/cli/src/tui/components/ConfigView.ts +++ b/sdk/apps/cli/src/tui/components/ConfigView.ts @@ -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 { export function getVisibleWindow( items: T[], selectedIndex: number, - maxVisible = MAX_MENU_ITEMS_VISIBLE, + maxVisible = MAX_CONFIG_ITEMS_VISIBLE, ): VisibleWindow { if (items.length <= maxVisible) { return { items, startIndex: 0 }; @@ -67,33 +81,193 @@ export function getVisibleWindow( 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(); + 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(); + 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(); + 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(); + 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, ); } diff --git a/sdk/apps/cli/src/tui/components/HistoryListView.ts b/sdk/apps/cli/src/tui/components/HistoryListView.ts index 1ee3404b5a..05a6027e16 100644 --- a/sdk/apps/cli/src/tui/components/HistoryListView.ts +++ b/sdk/apps/cli/src/tui/components/HistoryListView.ts @@ -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 { diff --git a/sdk/apps/cli/src/tui/components/StatusBar.ts b/sdk/apps/cli/src/tui/components/StatusBar.ts index 26e259f9f6..a55f759de2 100644 --- a/sdk/apps/cli/src/tui/components/StatusBar.ts +++ b/sdk/apps/cli/src/tui/components/StatusBar.ts @@ -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 diff --git a/sdk/apps/cli/src/tui/interactive-config.test.ts b/sdk/apps/cli/src/tui/interactive-config.test.ts new file mode 100644 index 0000000000..cd8d3ed0c4 --- /dev/null +++ b/sdk/apps/cli/src/tui/interactive-config.test.ts @@ -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 }); + } + }); +}); diff --git a/sdk/apps/cli/src/tui/interactive-config.ts b/sdk/apps/cli/src/tui/interactive-config.ts index 79369404b0..18a3a54dfb 100644 --- a/sdk/apps/cli/src/tui/interactive-config.ts +++ b/sdk/apps/cli/src/tui/interactive-config.ts @@ -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(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(); + 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 { 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, }); } diff --git a/sdk/apps/cli/src/tui/interactive-tui.ts b/sdk/apps/cli/src/tui/interactive-tui.ts index 3ad97d40d6..226ceea08e 100644 --- a/sdk/apps/cli/src/tui/interactive-tui.ts +++ b/sdk/apps/cli/src/tui/interactive-tui.ts @@ -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; loadWelcomeLine?: () => Promise; loadConfigData: () => Promise; + onToggleConfigItem?: ( + item: InteractiveConfigItem, + ) => Promise; 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( + () => 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); diff --git a/sdk/apps/cli/src/tui/interactive-welcome.ts b/sdk/apps/cli/src/tui/interactive-welcome.ts index c216377b5e..a52b8f7b37 100644 --- a/sdk/apps/cli/src/tui/interactive-welcome.ts +++ b/sdk/apps/cli/src/tui/interactive-welcome.ts @@ -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]", diff --git a/sdk/apps/cli/src/utils/approval.ts b/sdk/apps/cli/src/utils/approval.ts index f2110c6647..0a61deb57d 100644 --- a/sdk/apps/cli/src/utils/approval.ts +++ b/sdk/apps/cli/src/utils/approval.ts @@ -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] ?? ""); + }); }); } diff --git a/sdk/apps/cli/src/utils/chat-commands.test.ts b/sdk/apps/cli/src/utils/chat-commands.test.ts index c5c5eb87d6..1a6bd1838d 100644 --- a/sdk/apps/cli/src/utils/chat-commands.test.ts +++ b/sdk/apps/cli/src/utils/chat-commands.test.ts @@ -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); diff --git a/sdk/apps/cli/src/utils/chat-commands.ts b/sdk/apps/cli/src/utils/chat-commands.ts index 9570dc5c8b..9d0be01893 100644 --- a/sdk/apps/cli/src/utils/chat-commands.ts +++ b/sdk/apps/cli/src/utils/chat-commands.ts @@ -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; stop?: () => Promise | void; describe?: () => Promise | string; + fork?: () => + | Promise + | 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) => { diff --git a/sdk/apps/cli/src/utils/events.test.ts b/sdk/apps/cli/src/utils/events.test.ts index 3a68bde59a..a54a770e85 100644 --- a/sdk/apps/cli/src/utils/events.test.ts +++ b/sdk/apps/cli/src/utils/events.test.ts @@ -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( { diff --git a/sdk/apps/cli/src/utils/events.ts b/sdk/apps/cli/src/utils/events.ts index dec70e73cd..da98bc92cb 100644 --- a/sdk/apps/cli/src/utils/events.ts +++ b/sdk/apps/cli/src/utils/events.ts @@ -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`, diff --git a/sdk/apps/cli/src/utils/helpers.test.ts b/sdk/apps/cli/src/utils/helpers.test.ts index c4b5bce398..6223df32b5 100644 --- a/sdk/apps/cli/src/utils/helpers.test.ts +++ b/sdk/apps/cli/src/utils/helpers.test.ts @@ -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( diff --git a/sdk/apps/cli/src/utils/helpers.ts b/sdk/apps/cli/src/utils/helpers.ts index fcb0daa146..272b97d8e4 100644 --- a/sdk/apps/cli/src/utils/helpers.ts +++ b/sdk/apps/cli/src/utils/helpers.ts @@ -101,6 +101,30 @@ function summarizeRunCommandsInput(input: unknown): string { return ""; } +function formatAskQuestionInput(input: Record): 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; switch (toolName) { + case "ask_question": + return formatAskQuestionInput(obj); case "read_files": if (Array.isArray(obj.file_paths)) { return truncate(obj.file_paths.join(", "), 120); diff --git a/sdk/apps/cli/src/utils/hooks.test.ts b/sdk/apps/cli/src/utils/hooks.test.ts index 1c6c9de457..1dff562f72 100644 --- a/sdk/apps/cli/src/utils/hooks.test.ts +++ b/sdk/apps/cli/src/utils/hooks.test.ts @@ -1,54 +1,50 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@clinebot/core", async () => { - const actual = - await vi.importActual("@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"], + }), + ); }); }); diff --git a/sdk/apps/cli/src/utils/hooks.ts b/sdk/apps/cli/src/utils/hooks.ts index b286c7d8a8..93f09986d9 100644 --- a/sdk/apps/cli/src/utils/hooks.ts +++ b/sdk/apps/cli/src/utils/hooks.ts @@ -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; - 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 +>[0]; +type AgentHookStopErrorContext = Parameters< + NonNullable +>[0]; +type AgentHookToolCallEndContext = Parameters< + NonNullable +>[0]; +type AgentHookToolCallStartContext = Parameters< + NonNullable +>[0]; +type AgentHookTurnEndContext = Parameters< + NonNullable +>[0]; +type AgentHookSessionShutdownContext = Parameters< + NonNullable +>[0]; + +function mapParams(input: unknown): Record { + if (!input || typeof input !== "object") { + return {}; + } + const output: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + 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 { + 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; + verbose: boolean; + }, +): Promise { + 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; }): { hooks?: AgentHooks; shutdown: () => Promise; } { - 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 = { - 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 () => {}, }; } diff --git a/sdk/apps/cli/src/utils/hub-runtime.ts b/sdk/apps/cli/src/utils/hub-runtime.ts new file mode 100644 index 0000000000..9a50ba52a0 --- /dev/null +++ b/sdk/apps/cli/src/utils/hub-runtime.ts @@ -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 { + 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."); +} diff --git a/sdk/apps/cli/src/utils/internal-launch.test.ts b/sdk/apps/cli/src/utils/internal-launch.test.ts index 20a58293fa..7f298b57e1 100644 --- a/sdk/apps/cli/src/utils/internal-launch.test.ts +++ b/sdk/apps/cli/src/utils/internal-launch.test.ts @@ -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", - ); - }); }); diff --git a/sdk/apps/cli/src/utils/internal-launch.ts b/sdk/apps/cli/src/utils/internal-launch.ts index 591cf8bd26..8ab1bd28c4 100644 --- a/sdk/apps/cli/src/utils/internal-launch.ts +++ b/sdk/apps/cli/src/utils/internal-launch.ts @@ -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; } diff --git a/sdk/apps/cli/src/utils/rpc-runtime.ts b/sdk/apps/cli/src/utils/rpc-runtime.ts deleted file mode 100644 index a8444f830f..0000000000 --- a/sdk/apps/cli/src/utils/rpc-runtime.ts +++ /dev/null @@ -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 { - return await ensureSharedRpcRuntimeAddress(requestedAddress, { - resolveOwner: resolveCurrentCliRpcOwnerContext, - spawnIfNeeded: (address, owner) => { - spawnCliRpcStartDetached(address, owner); - }, - }); -} - -export async function ensureCliRpcRuntimeAddress( - requestedAddress: string, -): Promise { - return (await ensureCliRpcRuntime(requestedAddress)).address; -} diff --git a/sdk/apps/cli/src/utils/types.ts b/sdk/apps/cli/src/utils/types.ts index 7e6bb6988e..9f92c15793 100644 --- a/sdk/apps/cli/src/utils/types.ts +++ b/sdk/apps/cli/src/utils/types.ts @@ -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 { apiKey: string; knownModels?: Record; - 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; } diff --git a/sdk/apps/cli/vitest.config.ts b/sdk/apps/cli/vitest.config.ts index 2aa4dc3b88..6bcb0acf15 100644 --- a/sdk/apps/cli/vitest.config.ts +++ b/sdk/apps/cli/vitest.config.ts @@ -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$/, diff --git a/sdk/apps/code/README.md b/sdk/apps/code/README.md index 1eb67b9cac..0e74b123db 100644 --- a/sdk/apps/code/README.md +++ b/sdk/apps/code/README.md @@ -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 diff --git a/sdk/apps/code/package.json b/sdk/apps/code/package.json index aad8106268..680b942d71 100644 --- a/sdk/apps/code/package.json +++ b/sdk/apps/code/package.json @@ -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", diff --git a/sdk/apps/code/sidecar/ARCHITECTURE.md b/sdk/apps/code/sidecar/ARCHITECTURE.md index b43488cb22..fa5967ea7e 100644 --- a/sdk/apps/code/sidecar/ARCHITECTURE.md +++ b/sdk/apps/code/sidecar/ARCHITECTURE.md @@ -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 | diff --git a/sdk/apps/code/sidecar/chat-session.ts b/sdk/apps/code/sidecar/chat-session.ts index 650a3dcc4e..05f06eb540 100644 --- a/sdk/apps/code/sidecar/chat-session.ts +++ b/sdk/apps/code/sidecar/chat-session.ts @@ -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 { ? "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 { + 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 > = { start: handleStart, + attach: handleAttach, send: handleSend, stop: handleStop, abort: handleAbort, diff --git a/sdk/apps/code/sidecar/commands.ts b/sdk/apps/code/sidecar/commands.ts index e2a5a35d71..fd92e017ed 100644 --- a/sdk/apps/code/sidecar/commands.ts +++ b/sdk/apps/code/sidecar/commands.ts @@ -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 { + 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, ): Promise { - 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, + ) => { + 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; + }; 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(); - 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(); + 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") { diff --git a/sdk/apps/code/sidecar/context.ts b/sdk/apps/code/sidecar/context.ts index 483931ba09..d356fb15c6 100644 --- a/sdk/apps/code/sidecar/context.ts +++ b/sdk/apps/code/sidecar/context.ts @@ -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; + }, +): 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; + }, +): 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) + : 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 { 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; } diff --git a/sdk/apps/code/sidecar/types.ts b/sdk/apps/code/sidecar/types.ts index 0afcd6e4f8..3ecd6aaf20 100644 --- a/sdk/apps/code/sidecar/types.ts +++ b/sdk/apps/code/sidecar/types.ts @@ -1,4 +1,4 @@ -import type { ToolApprovalRequest, ToolApprovalResult } from "@clinebot/shared"; +import type { ClineCore, NodeHubClient } from "@clinebot/core"; export type JsonRecord = Record; @@ -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; + streamIndices: Map; wsClients: Set; pendingApprovals: Map; - sessionManager: import("@clinebot/core").LocalRuntimeHost | null; + sessionManager: ClineCore | null; + hubClient: NodeHubClient | null; workspaceRoot: string; unsubscribeSessionEvents: (() => void) | null; }; diff --git a/sdk/apps/code/tsconfig.dev.json b/sdk/apps/code/tsconfig.dev.json index fa3c5a08a4..a42eb13aa2 100644 --- a/sdk/apps/code/tsconfig.dev.json +++ b/sdk/apps/code/tsconfig.dev.json @@ -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" diff --git a/sdk/apps/code/tsconfig.json b/sdk/apps/code/tsconfig.json index d467ed22fa..ed639791fc 100644 --- a/sdk/apps/code/tsconfig.json +++ b/sdk/apps/code/tsconfig.json @@ -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" diff --git a/sdk/apps/code/webview/app/page.tsx b/sdk/apps/code/webview/app/page.tsx index d04c0c73df..268431cfd1 100644 --- a/sdk/apps/code/webview/app/page.tsx +++ b/sdk/apps/code/webview/app/page.tsx @@ -202,6 +202,7 @@ export default function Home() { threadId={activeThread.id} onDeleteSession={handleDeleteSession} onNewThread={handleNewThread} + onOpenSession={handleOpenSession} /> ) : 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} diff --git a/sdk/apps/code/webview/components/agent-sidebar.tsx b/sdk/apps/code/webview/components/agent-sidebar.tsx index 9070397bbf..da47e51254 100644 --- a/sdk/apps/code/webview/components/agent-sidebar.tsx +++ b/sdk/apps/code/webview/components/agent-sidebar.tsx @@ -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([]); const threadsRef = useRef([]); + const refreshTimeoutRef = useRef(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); diff --git a/sdk/apps/code/webview/components/ui/switch.tsx b/sdk/apps/code/webview/components/ui/switch.tsx index b587c48c60..929ecd9d12 100644 --- a/sdk/apps/code/webview/components/ui/switch.tsx +++ b/sdk/apps/code/webview/components/ui/switch.tsx @@ -13,7 +13,7 @@ function Switch({ diff --git a/sdk/apps/code/webview/components/views/chat/chat-input-bar.tsx b/sdk/apps/code/webview/components/views/chat/chat-input-bar.tsx index e3ec2d28e0..577c1f6486 100644 --- a/sdk/apps/code/webview/components/views/chat/chat-input-bar.tsx +++ b/sdk/apps/code/webview/components/views/chat/chat-input-bar.tsx @@ -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 = { 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()); const mentionLastRequestKeyRef = useRef(null); + + // ---- Slash command state ---- + const [slashOpen, setSlashOpen] = useState(false); + const [activeSlash, setActiveSlash] = useState(null); + const [slashCommands, setSlashCommands] = useState( + 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 (
{/* Input area */} @@ -359,6 +510,45 @@ export function ChatInputBar({
)}
+ {slashOpen && ( +
+ {filteredSlashCommands.length === 0 ? ( +
+ {slashLoading + ? "Loading commands..." + : "No matching commands"} +
+ ) : ( + <> + {filteredSlashCommands.map((cmd, index) => ( + + ))} + {slashLoading && ( +
+ Loading... +
+ )} + + )} +
+ )} {mentionOpen && (
{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(); diff --git a/sdk/apps/code/webview/components/views/chat/chat-messages.tsx b/sdk/apps/code/webview/components/views/chat/chat-messages.tsx index 7ba1a12c55..3f295c4dc2 100644 --- a/sdk/apps/code/webview/components/views/chat/chat-messages.tsx +++ b/sdk/apps/code/webview/components/views/chat/chat-messages.tsx @@ -9,6 +9,7 @@ import { Copy, FileEdit, FileSearch, + GitBranch, Loader2, RotateCcw, Search, @@ -45,6 +46,7 @@ type ChatMessagesProps = { onApproveToolApproval: (requestId: string) => void | Promise; onRejectToolApproval: (requestId: string) => void | Promise; onRestoreCheckpoint?: (runCount: number) => void | Promise; + onForkSession?: () => void | Promise; onStartChat?: (prompt: string) => void; }; @@ -78,6 +80,7 @@ function ChatMessagesImpl({ onApproveToolApproval, onRejectToolApproval, onRestoreCheckpoint, + onForkSession, onStartChat, }: ChatMessagesProps) { const scrollAreaRef = useRef(null); @@ -103,6 +106,8 @@ function ChatMessagesImpl({ Record >({}); const [copiedMessageId, setCopiedMessageId] = useState(null); + const [forkingMessageId, setForkingMessageId] = useState(null); + const [forkErrors, setForkErrors] = useState>({}); 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 (
void handleForkSession(message.id) + : undefined + } + forkPending={forkingMessageId === message.id} + forkError={forkErrors[message.id]} /> ))}
@@ -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}
) : null} + {!isUser && + !isError && + message.role === "assistant" && + onForkSession ? ( +
+ + {forkError ? ( + {forkError} + ) : null} +
+ ) : null}
); diff --git a/sdk/apps/code/webview/components/views/settings/account-view.tsx b/sdk/apps/code/webview/components/views/settings/account-view.tsx index af09a83f3d..0e944509b6 100644 --- a/sdk/apps/code/webview/components/views/settings/account-view.tsx +++ b/sdk/apps/code/webview/components/views/settings/account-view.tsx @@ -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 { - return await desktopClient.invoke("cline_account", { +async function fetchAccountUser(): Promise { + return await desktopClient.invoke("cline_account", { action: "clineAccount", operation: "fetchMe", }); } -async function fetchAccountBalance(): Promise { - return await desktopClient.invoke("cline_account", { +async function fetchAccountBalance(): Promise { + return await desktopClient.invoke("cline_account", { action: "clineAccount", operation: "fetchBalance", }); } async function fetchAccountOrganizations(): Promise< - RpcClineAccountOrganization[] + ClineAccountOrganization[] > { - return await desktopClient.invoke( + return await desktopClient.invoke( "cline_account", { action: "clineAccount", @@ -58,8 +58,8 @@ async function fetchAccountOrganizations(): Promise< async function fetchOrganizationBalance( organizationId: string, -): Promise { - return await desktopClient.invoke( +): Promise { + return await desktopClient.invoke( "cline_account", { action: "clineAccount", @@ -70,9 +70,9 @@ async function fetchOrganizationBalance( } async function fetchUsageTransactions(): Promise< - RpcClineAccountUsageTransaction[] + ClineAccountUsageTransaction[] > { - return await desktopClient.invoke( + return await desktopClient.invoke( "cline_account", { action: "clineAccount", @@ -84,21 +84,22 @@ async function fetchUsageTransactions(): Promise< async function fetchOrganizationUsageTransactions( organizationId: string, memberId?: string, -): Promise { - return await desktopClient.invoke< - RpcClineAccountOrganizationUsageTransaction[] - >("cline_account", { - action: "clineAccount", - operation: "fetchOrganizationUsageTransactions", - organizationId, - ...(memberId?.trim() ? { memberId: memberId.trim() } : {}), - }); +): Promise { + return await desktopClient.invoke( + "cline_account", + { + action: "clineAccount", + operation: "fetchOrganizationUsageTransactions", + organizationId, + ...(memberId?.trim() ? { memberId: memberId.trim() } : {}), + }, + ); } async function fetchPaymentTransactions(): Promise< - RpcClineAccountPaymentTransaction[] + ClineAccountPaymentTransaction[] > { - return await desktopClient.invoke( + return await desktopClient.invoke( "cline_account", { action: "clineAccount", @@ -117,19 +118,19 @@ export function AccountView() { ); // Overview data - const [user, setUser] = useState(null); - const [balance, setBalance] = useState(null); + const [user, setUser] = useState(null); + const [balance, setBalance] = useState(null); const [organizationBalance, setOrganizationBalance] = - useState(null); + useState(null); const [organizations, setOrganizations] = useState< - RpcClineAccountOrganization[] + ClineAccountOrganization[] >([]); const [overviewLoading, setOverviewLoading] = useState(true); const [overviewError, setOverviewError] = useState(null); // Usage data const [usageTransactions, setUsageTransactions] = useState< - RpcClineAccountUsageTransaction[] + ClineAccountUsageTransaction[] >([]); const [usageLoading, setUsageLoading] = useState(false); const [usageError, setUsageError] = useState(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(null); diff --git a/sdk/apps/code/webview/components/views/settings/extensions.view.tsx b/sdk/apps/code/webview/components/views/settings/extensions-view.tsx similarity index 59% rename from sdk/apps/code/webview/components/views/settings/extensions.view.tsx rename to sdk/apps/code/webview/components/views/settings/extensions-view.tsx index 5b0d893af7..0ddfe88f75 100644 --- a/sdk/apps/code/webview/components/views/settings/extensions.view.tsx +++ b/sdk/apps/code/webview/components/views/settings/extensions-view.tsx @@ -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; + url?: string; + headers?: Record; + 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( () => extensionListsCache?.agents ?? [], ); + const [plugins, setPlugins] = useState( + () => extensionListsCache?.plugins ?? [], + ); + const [tools, setTools] = useState( + () => extensionListsCache?.tools ?? [], + ); const [hooks, setHooks] = useState( () => extensionListsCache?.hooks ?? [], ); @@ -154,6 +216,9 @@ export function RulesView() { string | null >(() => extensionHookStatsCache?.hookExecutionSessionId ?? null); const [hookExecutionLoading, setHookExecutionLoading] = useState(false); + const [togglingToolIds, setTogglingToolIds] = useState>( + () => 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( + "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(() => { + 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(); + 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 (
@@ -481,45 +665,6 @@ export function RulesView() {
)} - {activeTab === "Workflows" && ( -
-

- Enabled workflows. Invoke one in chat with{" "} - - /workflow-name - - . -

- -
- {workflows.map((workflow) => ( -
-
- -

- {workflow.name} -

-
-

- {previewText(workflow.instructions)} -

-

- {workflow.path} -

-
- ))} - {workflows.length === 0 && ( -

- No enabled workflows found. -

- )} -
-
- )} - {activeTab === "Hooks" && (

@@ -677,34 +822,44 @@ export function RulesView() { {activeTab === "Skills" && (

- Enabled skills discovered from workspace and global skill - directories. + Enabled skills and workflows. Workflows can be invoked in chat + with{" "} + + /workflow-name + + .

- {skills.map((skill) => ( + {commandItems.map((item) => (
- + {item.type === "workflow" ? ( + + ) : ( + + )}

- {skill.name} + {item.name}

+ + {item.type} +

- {skill.description?.trim() || - previewText(skill.instructions)} + {item.description?.trim() || previewText(item.instructions)}

- {skill.path} + {item.path}

))} - {skills.length === 0 && ( + {commandItems.length === 0 && (

- No enabled skills found. + No enabled skills or workflows found.

)}
@@ -743,6 +898,252 @@ export function RulesView() {
)} + + {activeTab === "Plugins" && ( +
+

+ Plugins discovered from workspace and global plugin directories. +

+ +
+

+ Global Plugins +

+
+ {globalPlugins.map((plugin) => ( +
+
+ +

+ {plugin.name} +

+
+

+ {plugin.path} +

+
+ {( + pluginToolsByPluginKey.get( + `${plugin.name}:${plugin.path}`, + ) ?? [] + ).map((tool) => { + const isToggling = togglingToolIds.has(tool.id); + return ( +
+
+

+ {tool.name} +

+

+ {tool.description?.trim() || + "No description available."} +

+
+
+ + {tool.enabled ? "Enabled" : "Disabled"} + + { + void togglePluginTool(tool); + }} + disabled={isToggling} + aria-label={`Toggle ${tool.name}`} + /> +
+
+ ); + })} + {(pluginToolsByPluginKey.get( + `${plugin.name}:${plugin.path}`, + )?.length ?? 0) === 0 && ( +

+ No plugin tools found. +

+ )} +
+
+ ))} + {globalPlugins.length === 0 && ( +

+ No global plugins found. +

+ )} +
+
+ +
+

+ Project Plugins +

+
+ {projectPlugins.map((plugin) => ( +
+
+ +

+ {plugin.name} +

+
+

+ {plugin.path} +

+
+ {( + pluginToolsByPluginKey.get( + `${plugin.name}:${plugin.path}`, + ) ?? [] + ).map((tool) => { + const isToggling = togglingToolIds.has(tool.id); + return ( +
+
+

+ {tool.name} +

+

+ {tool.description?.trim() || + "No description available."} +

+
+
+ + {tool.enabled ? "Enabled" : "Disabled"} + + { + void togglePluginTool(tool); + }} + disabled={isToggling} + aria-label={`Toggle ${tool.name}`} + /> +
+
+ ); + })} + {(pluginToolsByPluginKey.get( + `${plugin.name}:${plugin.path}`, + )?.length ?? 0) === 0 && ( +

+ No plugin tools found. +

+ )} +
+
+ ))} + {projectPlugins.length === 0 && ( +

+ No project plugins found. +

+ )} +
+
+
+ )} + + {activeTab === "Tools" && ( +
+

+ Builtin tool groups and plugin-contributed tools available to the + runtime. +

+ +
+

+ Builtin Tools +

+
+ {builtinTools.map((tool) => ( +
+
+ +

+ {tool.name} +

+ + {tool.enabled + ? "enabled by default" + : "disabled by default"} + +
+

+ {tool.description?.trim() || "No description available."} +

+ {!!tool.headlessToolNames?.length && ( +

+ {tool.headlessToolNames.join(", ")} +

+ )} +
+ ))} + {builtinTools.length === 0 && ( +

+ No builtin tools found. +

+ )} +
+
+ +
+

+ Plugin Tools +

+
+ {pluginTools.map((tool) => ( +
+
+ +

+ {tool.name} +

+ {tool.pluginName && ( + + plugin: {tool.pluginName} + + )} + + {tool.enabled ? "enabled" : "disabled"} + +
+

+ {tool.description?.trim() || "No description available."} +

+ {tool.path && ( +

+ {tool.path} +

+ )} +
+ ))} + {pluginTools.length === 0 && ( +

+ No plugin tools found. +

+ )} +
+
+
+ )}
); diff --git a/sdk/apps/code/webview/components/views/settings/mcp-view.tsx b/sdk/apps/code/webview/components/views/settings/mcp-view.tsx index 2020d9cb76..2e20ecf2cf 100644 --- a/sdk/apps/code/webview/components/views/settings/mcp-view.tsx +++ b/sdk/apps/code/webview/components/views/settings/mcp-view.tsx @@ -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 { .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 { + 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, +): 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(null); const [busyServerName, setBusyServerName] = useState(null); + const [editorOpen, setEditorOpen] = useState(false); + const [editorMode, setEditorMode] = useState<"create" | "edit">("create"); + const [formState, setFormState] = useState(() => + createServerFormState(), + ); + const [formErrorMessage, setFormErrorMessage] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(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( @@ -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>((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 (
@@ -339,7 +425,7 @@ export function McpServersContent() { /> Refresh - @@ -356,14 +442,6 @@ export function McpServersContent() { > {settingsPath || "Open settings file"} -

{hasSettingsFile @@ -411,21 +489,11 @@ export function McpServersContent() {

- @@ -452,7 +512,7 @@ export function McpServersContent() { onCheckedChange={(enabled) => toggleServer(server, !enabled) } - disabled={!server.disabled && isBusy} + disabled={isBusy} aria-label={`Enable ${server.name}`} />
@@ -488,7 +548,7 @@ export function McpServersContent() { {server.env && Object.keys(server.env).length > 0 && (

Env:{" "} - {stringifyKeyValuePairs(server.env)} + {stringifyRedactedKeyValuePairs(server.env)}

)} {server.headers && @@ -507,6 +567,291 @@ export function McpServersContent() {
)} + + { + setEditorOpen(open); + if (!open) { + setFormErrorMessage(null); + } + }} + > + + + + {editorMode === "edit" ? "Edit MCP Server" : "Add MCP Server"} + + + Update the MCP server stored in{" "} + + {settingsPath || "cline_mcp_settings.json"} + + . + + + +
+
+ + + setFormState((current) => ({ + ...current, + name: event.target.value, + })) + } + placeholder="github" + /> +
+ +
+ + +
+ + {formState.transportType === "stdio" ? ( + <> +
+ + + setFormState((current) => ({ + ...current, + command: event.target.value, + })) + } + placeholder="npx" + /> +
+
+ +